Passed
Push — master ( 3d9789...a489aa )
by Thomas
02:14
created

FilePondField::getImageSizeConfigFromArray()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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