Passed
Push — master ( 33ac10...076b05 )
by Thomas
02:29
created

FilePondField::setRenameFile()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 1
eloc 2
c 1
b 0
f 1
nc 1
nop 1
dl 0
loc 4
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
    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
     * @link https://pqina.nl/filepond/docs/api/plugins/image-crop/
328
     * @link https://pqina.nl/filepond/docs/api/plugins/image-resize/
329
     * @link https://pqina.nl/filepond/docs/api/plugins/image-validate-size/
330
     * @param int $width
331
     * @param int $height
332
     * @param string $mode min|max|crop|resize|crop_resize
333
     * @return $this
334
     */
335
    public function setImageSize($width, $height, $mode = null)
336
    {
337
        $config = $this->getImageSizeConfig($width, $height, $mode);
338
        foreach ($config as $k => $v) {
339
            $this->addFilePondConfig($k, $v);
340
        }
341
342
        // We need a custom poster size
343
        $this->adjustPosterSize($width, $height);
344
345
        return $this;
346
    }
347
348
    /**
349
     * This is a frontend alternative to setRenamePattern
350
     *
351
     * @link https://pqina.nl/filepond/docs/api/plugins/file-rename/
352
     * @param string $name
353
     * @return $this
354
     */
355
    public function setRenameFile($name)
356
    {
357
        $this->addFilePondConfig('fileRenameFunction', $name);
358
        return $this;
359
    }
360
361
    /**
362
     * @param int $width
363
     * @param int $height
364
     * @return void
365
     */
366
    protected function adjustPosterSize($width, $height)
367
    {
368
        // If the height is smaller than our default, make smaller
369
        if ($height < self::getDefaultPosterHeight()) {
370
            $this->posterHeight = $height;
371
            $this->posterWidth = $width;
372
        } else {
373
            // Adjust width to keep aspect ratio with our default height
374
            $ratio = $height / self::getDefaultPosterHeight();
375
            $this->posterWidth = $width / $ratio;
376
        }
377
    }
378
379
    /**
380
     * @return int
381
     */
382
    public function getPosterWidth()
383
    {
384
        if ($this->posterWidth) {
385
            return $this->posterWidth;
386
        }
387
        return self::getDefaultPosterWidth();
388
    }
389
390
    /**
391
     * @return int
392
     */
393
    public function getPosterHeight()
394
    {
395
        if ($this->posterHeight) {
396
            return $this->posterHeight;
397
        }
398
        return self::getDefaultPosterHeight();
399
    }
400
401
    /**
402
     * Return the config applied for this field
403
     *
404
     * Typically converted to json and set in a data attribute
405
     *
406
     * @return array
407
     */
408
    public function getFilePondConfig()
409
    {
410
        $name = $this->getName();
411
        $multiple = $this->getIsMultiUpload();
412
413
        // Multi uploads need []
414
        if ($multiple && strpos($name, '[]') === false) {
415
            $name .= '[]';
416
            $this->setName($name);
417
        }
418
419
        $i18nConfig = [
420
            'labelIdle' => _t('FilePondField.labelIdle', 'Drag & Drop your files or <span class="filepond--label-action"> Browse </span>'),
421
            'labelFileProcessing' => _t('FilePondField.labelFileProcessing', 'Uploading'),
422
            'labelFileProcessingComplete' => _t('FilePondField.labelFileProcessingComplete', 'Upload complete'),
423
            'labelFileProcessingAborted' => _t('FilePondField.labelFileProcessingAborted', 'Upload cancelled'),
424
            'labelTapToCancel' => _t('FilePondField.labelTapToCancel', 'tap to cancel'),
425
            'labelTapToRetry' => _t('FilePondField.labelTapToCancel', 'tap to retry'),
426
            'labelTapToUndo' => _t('FilePondField.labelTapToCancel', 'tap to undo'),
427
        ];
428
429
        // Base config
430
        $config = [
431
            'name' => $name, // This will also apply to the hidden fields
432
            'allowMultiple' => $multiple,
433
            'maxFiles' => $this->getAllowedMaxFileNumber(),
434
            'maxFileSize' => $this->getMaxFileSize(),
435
            'server' => $this->getServerOptions(),
436
            'files' => $this->getExistingUploadsData(),
437
        ];
438
439
        $acceptedFileTypes = $this->getAcceptedFileTypes();
440
        if (!empty($acceptedFileTypes)) {
441
            $config['acceptedFileTypes'] = array_values($acceptedFileTypes);
442
        }
443
444
        // image poster
445
        // @link https://pqina.nl/filepond/docs/api/plugins/file-poster/#usage
446
        if (self::config()->enable_poster) {
447
            $config['filePosterHeight'] = self::config()->poster_height ?? self::DEFAULT_POSTER_HEIGHT;
448
        }
449
450
        // image validation/crop based on record
451
        $record = $this->getForm()->getRecord();
452
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
453
            $sizes = $record->config()->image_sizes;
454
            $name = $this->getSafeName();
455
            if ($sizes && isset($sizes[$name])) {
456
                $newConfig = $this->getImageSizeConfigFromArray($sizes[$name]);
457
                $config = array_merge($config, $newConfig);
458
                $this->adjustPosterSize($sizes[$name][0], $sizes[$name][1]);
459
            }
460
        }
461
462
463
        // Any custom setting will override the base ones
464
        $config = array_merge($config, $i18nConfig, $this->filePondConfig);
465
466
        return $config;
467
    }
468
469
    /**
470
     * Compute best size for chunks based on server settings
471
     *
472
     * @return int
473
     */
474
    protected function computeMaxChunkSize()
475
    {
476
        $maxUpload = Convert::memstring2bytes(ini_get('upload_max_filesize'));
477
        $maxPost = Convert::memstring2bytes(ini_get('post_max_size'));
478
479
        // ~90%, allow some overhead
480
        return round(min($maxUpload, $maxPost) * 0.9);
481
    }
482
483
    /**
484
     * @inheritDoc
485
     */
486
    public function setValue($value, $record = null)
487
    {
488
        // Normalize values to something similar to UploadField usage
489
        if (is_numeric($value)) {
490
            $value = ['Files' => [$value]];
491
        } elseif (is_array($value) && empty($value['Files'])) {
492
            $value = ['Files' => $value];
493
        }
494
        // Track existing record data
495
        if ($record) {
496
            $name = $this->name;
497
            if ($record instanceof DataObject && $record->hasMethod($name)) {
498
                $data = $record->$name();
499
                // Wrap
500
                if ($data instanceof DataObject) {
501
                    $data = new ArrayList([$data]);
502
                }
503
                foreach ($data as $uploadedItem) {
504
                    $this->trackFileID($uploadedItem->ID);
505
                }
506
            }
507
        }
508
        return parent::setValue($value, $record);
509
    }
510
511
    /**
512
     * Configure our endpoint
513
     *
514
     * @link https://pqina.nl/filepond/docs/patterns/api/server/
515
     * @return array
516
     */
517
    public function getServerOptions()
518
    {
519
        if (!empty($this->customServerConfig)) {
520
            return $this->customServerConfig;
521
        }
522
        if (!$this->getForm()) {
523
            throw new LogicException(
524
                'Field must be associated with a form to call getServerOptions(). Please use $field->setForm($form);'
525
            );
526
        }
527
        $endpoint = $this->getChunkUploads() ? 'chunk' : 'upload';
528
        $server = [
529
            'process' => $this->getUploadEnabled() ? $this->getLinkParameters($endpoint) : null,
530
            'fetch' => null,
531
            'revert' => $this->getUploadEnabled() ? $this->getLinkParameters('revert') : null,
532
        ];
533
        if ($this->getUploadEnabled() && $this->getChunkUploads()) {
534
            $server['fetch'] =  $this->getLinkParameters($endpoint . "?fetch=");
535
            $server['patch'] =  $this->getLinkParameters($endpoint . "?patch=");
536
        }
537
        return $server;
538
    }
539
540
    /**
541
     * Configure the following parameters:
542
     *
543
     * url : Path to the end point
544
     * method : Request method to use
545
     * withCredentials : Toggles the XMLHttpRequest withCredentials on or off
546
     * headers : An object containing additional headers to send
547
     * timeout : Timeout for this action
548
     * onload : Called when server response is received, useful for getting the unique file id from the server response
549
     * onerror : Called when server error is received, receis the response body, useful to select the relevant error data
550
     *
551
     * @param string $action
552
     * @return array
553
     */
554
    protected function getLinkParameters($action)
555
    {
556
        $form = $this->getForm();
557
        $token = $form->getSecurityToken()->getValue();
558
        $record = $form->getRecord();
559
560
        $headers = [
561
            'X-SecurityID' => $token
562
        ];
563
        // Allow us to track the record instance
564
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
565
            $headers['X-RecordClassName'] = get_class($record);
566
            $headers['X-RecordID'] = $record->ID;
567
        }
568
        return [
569
            'url' => $this->Link($action),
570
            'headers' => $headers,
571
        ];
572
    }
573
574
    /**
575
     * The maximum size of a file, for instance 5MB or 750KB
576
     * Suitable for JS usage
577
     *
578
     * @return string
579
     */
580
    public function getMaxFileSize()
581
    {
582
        return str_replace(' ', '', File::format_size($this->getValidator()->getAllowedMaxFileSize()));
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
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 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) {
628
                    $w = $this->posterWidth;
629
                }
630
                $h = self::getDefaultPosterHeight();
631
                if ($this->posterHeight) {
632
                    $h = $this->posterHeight;
633
                }
634
                $resizedImage = $file->Fill($w, $h);
635
                if ($resizedImage) {
636
                    $poster = $resizedImage->getAbsoluteURL();
637
                    $existingUpload['options']['metadata']['poster'] = $poster;
638
                }
639
            }
640
            $existingUploads[] = $existingUpload;
641
        }
642
        return $existingUploads;
643
    }
644
645
    /**
646
     * @return int
647
     */
648
    public static function getDefaultPosterWidth()
649
    {
650
        return self::config()->poster_width ?? self::DEFAULT_POSTER_WIDTH;
651
    }
652
653
    /**
654
     * @return int
655
     */
656
    public static function getDefaultPosterHeight()
657
    {
658
        return self::config()->poster_height ?? self::DEFAULT_POSTER_HEIGHT;
659
    }
660
661
    /**
662
     * Requirements are NOT versioned since filepond is regularly updated
663
     *
664
     * @return void
665
     */
666
    public static function Requirements()
667
    {
668
        $baseDir = self::BASE_CDN;
669
        if (!self::config()->use_cdn || self::config()->use_bundle) {
670
            // We need some kind of base url to serve as a starting point
671
            $asset = ModuleResourceLoader::resourceURL('lekoala/silverstripe-filepond:javascript/FilePondField.js');
672
            $baseDir = dirname($asset) . "/cdn";
673
        }
674
        $baseDir = rtrim($baseDir, '/');
675
676
        // It will load everything regardless of enabled plugins
677
        if (self::config()->use_bundle) {
678
            Requirements::css('lekoala/silverstripe-filepond:javascript/bundle.css');
679
            Requirements::javascript('lekoala/silverstripe-filepond:javascript/bundle.js');
680
        } else {
681
            // Polyfill to ensure max compatibility
682
            if (self::config()->enable_polyfill) {
683
                Requirements::javascript("$baseDir/filepond-polyfill/dist/filepond-polyfill.min.js");
684
            }
685
686
            // File/image validation plugins
687
            if (self::config()->enable_validation) {
688
                Requirements::javascript("$baseDir/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.min.js");
689
                Requirements::javascript("$baseDir/filepond-plugin-file-validate-size/dist/filepond-plugin-file-validate-size.min.js");
690
                Requirements::javascript("$baseDir/filepond-plugin-image-validate-size/dist/filepond-plugin-image-validate-size.min.js");
691
            }
692
693
            // Poster plugins
694
            if (self::config()->enable_poster) {
695
                Requirements::javascript("$baseDir/filepond-plugin-file-metadata/dist/filepond-plugin-file-metadata.min.js");
696
                Requirements::css("$baseDir/filepond-plugin-file-poster/dist/filepond-plugin-file-poster.min.css");
697
                Requirements::javascript("$baseDir/filepond-plugin-file-poster/dist/filepond-plugin-file-poster.min.js");
698
            }
699
700
            // Image plugins
701
            if (self::config()->enable_image) {
702
                Requirements::javascript("$baseDir/filepond-plugin-image-exif-orientation/dist/filepond-plugin-image-exif-orientation.min.js");
703
                Requirements::css("$baseDir/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css");
704
                Requirements::javascript("$baseDir/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.js");
705
                Requirements::javascript("$baseDir/filepond-plugin-image-transform/dist/filepond-plugin-image-transform.min.js");
706
                Requirements::javascript("$baseDir/filepond-plugin-image-resize/dist/filepond-plugin-image-resize.min.js");
707
                Requirements::javascript("$baseDir/filepond-plugin-image-crop/dist/filepond-plugin-image-crop.min.js");
708
            }
709
710
            // Base elements
711
            Requirements::javascript("$baseDir/filepond-plugin-file-rename/dist/filepond-plugin-file-rename.min.js");
712
            Requirements::css("$baseDir/filepond/dist/filepond.min.css");
713
            Requirements::javascript("$baseDir/filepond/dist/filepond.min.js");
714
        }
715
716
        // Our custom init
717
        Requirements::javascript('lekoala/silverstripe-filepond:javascript/FilePondField.js');
718
719
        // In the cms, init will not be triggered
720
        // Or you could use simpler instead
721
        if (self::config()->enable_ajax_init && Director::is_ajax()) {
722
            Requirements::javascript('lekoala/silverstripe-filepond:javascript/FilePondField-init.js?t=' . time());
723
        }
724
    }
725
726
    public function FieldHolder($properties = array())
727
    {
728
        $config = $this->getFilePondConfig();
729
730
        $this->setAttribute('data-config', json_encode($config));
731
732
        if (self::config()->enable_requirements) {
733
            self::Requirements();
734
        }
735
736
        return parent::FieldHolder($properties);
737
    }
738
739
    /**
740
     * Check the incoming request
741
     *
742
     * @param HTTPRequest $request
743
     * @return array
744
     */
745
    public function prepareUpload(HTTPRequest $request)
746
    {
747
        $name = $this->getName();
748
        $tmpFile = $request->postVar($name);
749
        if (!$tmpFile) {
750
            throw new RuntimeException("No file");
751
        }
752
        $tmpFile = $this->normalizeTempFile($tmpFile);
753
754
        // Update $tmpFile with a better name
755
        if ($this->renamePattern) {
756
            $tmpFile['name'] = $this->changeFilenameWithPattern(
757
                $tmpFile['name'],
758
                $this->renamePattern
759
            );
760
        }
761
762
        return $tmpFile;
763
    }
764
765
    /**
766
     * @param HTTPRequest $request
767
     * @return void
768
     */
769
    protected function securityChecks(HTTPRequest $request)
770
    {
771
        if ($this->isDisabled() || $this->isReadonly()) {
772
            throw new RuntimeException("Field is disabled or readonly");
773
        }
774
775
        // CSRF check
776
        $token = $this->getForm()->getSecurityToken();
777
        if (!$token->checkRequest($request)) {
778
            throw new RuntimeException("Invalid token");
779
        }
780
    }
781
782
    /**
783
     * @param File $file
784
     * @param HTTPRequest $request
785
     * @return void
786
     */
787
    protected function setFileDetails(File $file, HTTPRequest $request)
788
    {
789
        // Mark as temporary until properly associated with a record
790
        // Files will be unmarked later on by saveInto method
791
        $file->IsTemporary = true;
792
793
        // We can also track the record
794
        $RecordID = $request->getHeader('X-RecordID');
795
        $RecordClassName = $request->getHeader('X-RecordClassName');
796
        if (!$file->ObjectID) {
797
            $file->ObjectID = $RecordID;
798
        }
799
        if (!$file->ObjectClass) {
800
            $file->ObjectClass = $RecordClassName;
801
        }
802
803
        if ($file->isChanged()) {
804
            // If possible, prevent creating a version for no reason
805
            // @link https://docs.silverstripe.org/en/4/developer_guides/model/versioning/#writing-changes-to-a-versioned-dataobject
806
            if ($file->hasExtension(Versioned::class)) {
807
                $file->writeWithoutVersion();
808
            } else {
809
                $file->write();
810
            }
811
        }
812
    }
813
814
    /**
815
     * Creates a single file based on a form-urlencoded upload.
816
     *
817
     * 1 client uploads file my-file.jpg as multipart/form-data using a POST request
818
     * 2 server saves file to unique location tmp/12345/my-file.jpg
819
     * 3 server returns unique location id 12345 in text/plain response
820
     * 4 client stores unique id 12345 in a hidden input field
821
     * 5 client submits the FilePond parent form containing the hidden input field with the unique id
822
     * 6 server uses the unique id to move tmp/12345/my-file.jpg to its final location and remove the tmp/12345 folder
823
     *
824
     * Along with the file object, FilePond also sends the file metadata to the server, both these objects are given the same name.
825
     *
826
     * @param HTTPRequest $request
827
     * @return HTTPResponse
828
     */
829
    public function upload(HTTPRequest $request)
830
    {
831
        try {
832
            $this->securityChecks($request);
833
            $tmpFile = $this->prepareUpload($request);
834
        } catch (Exception $ex) {
835
            return $this->httpError(400, $ex->getMessage());
836
        }
837
838
        $file = $this->saveTemporaryFile($tmpFile, $error);
839
840
        // Handle upload errors
841
        if ($error) {
842
            $this->getUpload()->clearErrors();
843
            return $this->httpError(400, json_encode($error));
844
        }
845
846
        // File can be an AssetContainer and not a DataObject
847
        if ($file instanceof DataObject) {
848
            $this->setFileDetails($file, $request);
849
        }
850
851
        $this->getUpload()->clearErrors();
852
        $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...
853
        $this->trackFileID($fileId);
854
855
        if (self::config()->auto_clear_temp_folder) {
856
            // Set a limit of 100 because otherwise it would be really slow
857
            self::clearTemporaryUploads(true, 100);
858
        }
859
860
        // server returns unique location id 12345 in text/plain response
861
        $response = new HTTPResponse($fileId);
862
        $response->addHeader('Content-Type', 'text/plain');
863
        return $response;
864
    }
865
866
    /**
867
     * @link https://pqina.nl/filepond/docs/api/server/#process-chunks
868
     * @param HTTPRequest $request
869
     * @return HTTPResponse
870
     */
871
    public function chunk(HTTPRequest $request)
872
    {
873
        try {
874
            $this->securityChecks($request);
875
        } catch (Exception $ex) {
876
            return $this->httpError(400, $ex->getMessage());
877
        }
878
879
        $method = $request->httpMethod();
880
881
        // The random token is returned as a query string
882
        $id = $request->getVar('patch');
883
884
        // FilePond will send a POST request (without file) to start a chunked transfer,
885
        // expecting to receive a unique transfer id in the response body, it'll add the Upload-Length header to this request.
886
        if ($method == "POST") {
887
            // Initial post payload doesn't contain name
888
            // It would be better to return some kind of random token instead
889
            // But FilePond stores the id upon the first request :-(
890
            $file = new File();
891
            $this->setFileDetails($file, $request);
892
            $fileId = $file->ID;
893
            $this->trackFileID($fileId);
894
            $response = new HTTPResponse($fileId, 200);
895
            $response->addHeader('Content-Type', 'text/plain');
896
            return $response;
897
        }
898
899
        // location of patch files
900
        $filePath = TEMP_PATH . "/filepond-" . $id;
901
902
        // FilePond will send a HEAD request to determine which chunks have already been uploaded,
903
        // expecting the file offset of the next expected chunk in the Upload-Offset response header.
904
        if ($method == "HEAD") {
905
            $nextOffset = 0;
906
            while (is_file($filePath . '.patch.' . $nextOffset)) {
907
                $nextOffset++;
908
            }
909
910
            $response = new HTTPResponse($nextOffset, 200);
911
            $response->addHeader('Content-Type', 'text/plain');
912
            $response->addHeader('Upload-Offset', $nextOffset);
913
            return $response;
914
        }
915
916
        // FilePond will send a PATCH request to push a chunk to the server.
917
        // Each of these requests is accompanied by a Content-Type, Upload-Offset, Upload-Name, and Upload-Length header.
918
        if ($method != "PATCH") {
919
            return $this->httpError(400, "Invalid method");
920
        }
921
922
        // The name of the file being transferred
923
        $uploadName = $request->getHeader('Upload-Name');
924
        // The offset of the chunk being transferred (starts with 0)
925
        $offset = $request->getHeader('Upload-Offset');
926
        // The total size of the file being transferred (in bytes)
927
        $length = (int) $request->getHeader('Upload-Length');
928
929
        // should be numeric values, else exit
930
        if (!is_numeric($offset) || !is_numeric($length)) {
931
            return $this->httpError(400, "Invalid offset or length");
932
        }
933
934
        // write patch file for this request
935
        file_put_contents($filePath . '.patch.' . $offset, $request->getBody());
936
937
        // calculate total size of patches
938
        $size = 0;
939
        $patch = glob($filePath . '.patch.*');
940
        foreach ($patch as $filename) {
941
            $size += filesize($filename);
942
        }
943
        // if total size equals length of file we have gathered all patch files
944
        if ($size >= $length) {
945
            // create output file
946
            $outputFile = fopen($filePath, 'wb');
947
            // write patches to file
948
            foreach ($patch as $filename) {
949
                // get offset from filename
950
                list($dir, $offset) = explode('.patch.', $filename, 2);
951
                // read patch and close
952
                $patchFile = fopen($filename, 'rb');
953
                $patchContent = fread($patchFile, filesize($filename));
954
                fclose($patchFile);
955
956
                // apply patch
957
                fseek($outputFile, (int) $offset);
958
                fwrite($outputFile, $patchContent);
959
            }
960
            // remove patches
961
            foreach ($patch as $filename) {
962
                unlink($filename);
963
            }
964
            // done with file
965
            fclose($outputFile);
966
967
            // Finalize real filename
968
969
            // We need to class this as it mutates the state and set the record if any
970
            $relationClass = $this->getRelationAutosetClass(null);
0 ignored issues
show
Unused Code introduced by
The assignment to $relationClass is dead and can be removed.
Loading history...
971
            $realFilename = $this->getFolderName() . "/" . $uploadName;
972
            if ($this->renamePattern) {
973
                $realFilename = $this->changeFilenameWithPattern(
974
                    $realFilename,
975
                    $this->renamePattern
976
                );
977
            }
978
979
            // write output file to asset store
980
            $file = $this->getFileByID($id);
981
            if (!$file) {
982
                return $this->httpError(400, "File $id not found");
983
            }
984
            $file->setFromLocalFile($filePath);
985
            $file->setFilename($realFilename);
986
            $file->Title = $uploadName;
987
            // Set proper class
988
            $relationClass = File::get_class_for_file_extension(
989
                File::get_file_extension($realFilename)
990
            );
991
            $file->setClassName($relationClass);
992
            $file->write();
993
            // Reload file instance to get the right class
994
            // it is not cached so we should get a fresh record
995
            $file = $this->getFileByID($id);
996
            // since we don't go through our upload object, call extension manually
997
            $file->extend('onAfterUpload');
998
        }
999
        $response = new HTTPResponse('', 204);
1000
        return $response;
1001
    }
1002
1003
    /**
1004
     * @link https://pqina.nl/filepond/docs/api/server/#revert
1005
     * @param HTTPRequest $request
1006
     * @return HTTPResponse
1007
     */
1008
    public function revert(HTTPRequest $request)
1009
    {
1010
        try {
1011
            $this->securityChecks($request);
1012
        } catch (Exception $ex) {
1013
            return $this->httpError(400, $ex->getMessage());
1014
        }
1015
1016
        $method = $request->httpMethod();
1017
1018
        if ($method != "DELETE") {
1019
            return $this->httpError(400, "Invalid method");
1020
        }
1021
1022
        $fileID = (int) $request->getBody();
1023
        if (!in_array($fileID, $this->getTrackedIDs())) {
1024
            return $this->httpError(400, "Invalid ID");
1025
        }
1026
        $file = File::get()->byID($fileID);
1027
        if (!$file->IsTemporary) {
1028
            return $this->httpError(400, "Invalid file");
1029
        }
1030
        if (!$file->canDelete()) {
1031
            return $this->httpError(400, "Cannot delete file");
1032
        }
1033
        $file->delete();
1034
        $response = new HTTPResponse('', 200);
1035
        return $response;
1036
    }
1037
1038
    /**
1039
     * Clear temp folder that should not contain any file other than temporary
1040
     *
1041
     * @param boolean $doDelete Set to true to actually delete the files, otherwise it's just a dry-run
1042
     * @param int $limit
1043
     * @return File[] List of files removed
1044
     */
1045
    public static function clearTemporaryUploads($doDelete = false, $limit = 0)
1046
    {
1047
        $tempFiles = File::get()->filter('IsTemporary', true);
1048
        if ($limit) {
1049
            $tempFiles = $tempFiles->limit($limit);
1050
        }
1051
1052
        $threshold = self::config()->auto_clear_threshold;
1053
1054
        // Set a default threshold if none set
1055
        if (!$threshold) {
1056
            if (Director::isDev()) {
1057
                $threshold = '-10 minutes';
1058
            } else {
1059
                $threshold = '-1 day';
1060
            }
1061
        }
1062
        if (is_int($threshold)) {
1063
            $thresholdTime = time() - $threshold;
1064
        } else {
1065
            $thresholdTime = strtotime($threshold);
1066
        }
1067
1068
        // Update query to avoid fetching unecessary records
1069
        $tempFiles = $tempFiles->filter("Created:LessThan", date('Y-m-d H:i:s', $thresholdTime));
1070
1071
        $filesDeleted = [];
1072
        foreach ($tempFiles as $tempFile) {
1073
            $createdTime = strtotime($tempFile->Created);
1074
            if ($createdTime < $thresholdTime) {
1075
                $filesDeleted[] = $tempFile;
1076
                if ($doDelete) {
1077
                    if ($tempFile->hasExtension(Versioned::class)) {
1078
                        $tempFile->deleteFromStage(Versioned::LIVE);
1079
                        $tempFile->deleteFromStage(Versioned::DRAFT);
1080
                    } else {
1081
                        $tempFile->delete();
1082
                    }
1083
                }
1084
            }
1085
        }
1086
        return $filesDeleted;
1087
    }
1088
1089
    /**
1090
     * Allows tracking uploaded ids to prevent unauthorized attachements
1091
     *
1092
     * @param int $fileId
1093
     * @return void
1094
     */
1095
    public function trackFileID($fileId)
1096
    {
1097
        $session = $this->getRequest()->getSession();
1098
        $uploadedIDs = $this->getTrackedIDs();
1099
        if (!in_array($fileId, $uploadedIDs)) {
1100
            $uploadedIDs[] = $fileId;
1101
        }
1102
        $session->set('FilePond', $uploadedIDs);
1103
    }
1104
1105
    /**
1106
     * Get all authorized tracked ids
1107
     * @return array
1108
     */
1109
    public function getTrackedIDs()
1110
    {
1111
        $session = $this->getRequest()->getSession();
1112
        $uploadedIDs = $session->get('FilePond');
1113
        if ($uploadedIDs) {
1114
            return $uploadedIDs;
1115
        }
1116
        return [];
1117
    }
1118
1119
    public function saveInto(DataObjectInterface $record)
1120
    {
1121
        // Note that the list of IDs is based on the value sent by the user
1122
        // It can be spoofed because checks are minimal (by default, canView = true and only check if isInDB)
1123
        $IDs = $this->getItemIDs();
1124
1125
        $Member = Security::getCurrentUser();
1126
1127
        // Ensure the files saved into the DataObject have been tracked (either because already on the DataObject or uploaded by the user)
1128
        $trackedIDs = $this->getTrackedIDs();
1129
        foreach ($IDs as $ID) {
1130
            if (!in_array($ID, $trackedIDs)) {
1131
                throw new ValidationException("Invalid file ID : $ID");
1132
            }
1133
        }
1134
1135
        // Move files out of temporary folder
1136
        foreach ($IDs as $ID) {
1137
            $file = $this->getFileByID($ID);
1138
            if ($file && $file->IsTemporary) {
1139
                // The record does not have an ID which is a bad idea to attach the file to it
1140
                if (!$record->ID) {
1141
                    $record->write();
1142
                }
1143
                // Check if the member is owner
1144
                if ($Member && $Member->ID != $file->OwnerID) {
1145
                    throw new ValidationException("Failed to authenticate owner");
1146
                }
1147
                $file->IsTemporary = false;
1148
                $file->ObjectID = $record->ID;
1149
                $file->ObjectClass = get_class($record);
1150
                $file->write();
1151
            } else {
1152
                // File was uploaded earlier, no need to do anything
1153
            }
1154
        }
1155
1156
        // Proceed
1157
        return parent::saveInto($record);
1158
    }
1159
1160
    public function Type()
1161
    {
1162
        return 'filepond';
1163
    }
1164
}
1165