Passed
Push — master ( 873943...04d1c2 )
by Thomas
02:19
created

FilePondField::setCustomServerConfig()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 2
c 1
b 0
f 0
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\Security\Member;
15
use SilverStripe\Control\Director;
16
use SilverStripe\Security\Security;
17
use SilverStripe\View\Requirements;
18
use SilverStripe\Control\HTTPRequest;
19
use SilverStripe\Versioned\Versioned;
20
use SilverStripe\Control\HTTPResponse;
21
use SilverStripe\ORM\DataObjectInterface;
22
use SilverStripe\ORM\ValidationException;
23
use SilverStripe\Core\Manifest\ModuleResourceLoader;
24
25
/**
26
 * A FilePond field
27
 */
28
class FilePondField extends AbstractUploadField
29
{
30
31
    /**
32
     * @config
33
     * @var array
34
     */
35
    private static $allowed_actions = [
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
36
        'upload',
37
        'chunk',
38
        'revert',
39
    ];
40
41
    /**
42
     * @config
43
     * @var boolean
44
     */
45
    private static $enable_requirements = true;
46
47
    /**
48
     * @config
49
     * @var boolean
50
     */
51
    private static $enable_validation = true;
52
53
    /**
54
     * @config
55
     * @var boolean
56
     */
57
    private static $enable_poster = false;
58
59
    /**
60
     * @config
61
     * @var boolean
62
     */
63
    private static $enable_image = false;
64
65
    /**
66
     * @config
67
     * @var boolean
68
     */
69
    private static $enable_polyfill = true;
70
71
    /**
72
     * @config
73
     * @var boolean
74
     */
75
    private static $enable_ajax_init = true;
76
77
    /**
78
     * @config
79
     * @var boolean
80
     */
81
    private static $chunk_by_default = false;
82
83
    /**
84
     * @config
85
     * @var boolean
86
     */
87
    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...
88
89
    /**
90
     * @config
91
     * @var boolean
92
     */
93
    private static $auto_clear_temp_folder = true;
94
95
    /**
96
     * @config
97
     * @var int
98
     */
99
    private static $auto_clear_threshold = true;
100
101
    /**
102
     * @config
103
     * @var boolean
104
     */
105
    private static $use_cdn = true;
106
107
    /**
108
     * @config
109
     * @var boolean
110
     */
111
    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...
112
113
    /**
114
     * @config
115
     * @var int
116
     */
117
    private static $poster_width = 352;
118
119
    /**
120
     * @config
121
     * @var int
122
     */
123
    private static $poster_height = 264;
124
125
    /**
126
     * @var array
127
     */
128
    protected $filePondConfig = [];
129
130
    /**
131
     * @var array
132
     */
133
    protected $customServerConfig = [];
134
135
    /**
136
     * Create a new file field.
137
     *
138
     * @param string $name The internal field name, passed to forms.
139
     * @param string $title The field label.
140
     * @param SS_List $items Items assigned to this field
141
     */
142
    public function __construct($name, $title = null, SS_List $items = null)
143
    {
144
        parent::__construct($name, $title, $items);
145
146
        if (self::config()->chunk_by_default) {
147
            $this->setChunkUploads(true);
148
        }
149
    }
150
151
    /**
152
     * Set a custom config value for this field
153
     *
154
     * @link https://pqina.nl/filepond/docs/patterns/api/filepond-instance/#properties
155
     * @param string $k
156
     * @param string $v
157
     * @return $this
158
     */
159
    public function addFilePondConfig($k, $v)
160
    {
161
        $this->filePondConfig[$k] = $v;
162
        return $this;
163
    }
164
165
    /**
166
     * Custom configuration applied to this field
167
     *
168
     * @return array
169
     */
170
    public function getCustomFilePondConfig()
171
    {
172
        return $this->filePondConfig;
173
    }
174
175
    /**
176
     * Get the value of chunkUploads
177
     * @return bool
178
     */
179
    public function getChunkUploads()
180
    {
181
        if (!isset($this->filePondConfig['chunkUploads'])) {
182
            return false;
183
        }
184
        return $this->filePondConfig['chunkUploads'];
185
    }
186
187
    /**
188
     * Get the value of customServerConfig
189
     * @return array
190
     */
191
    public function getCustomServerConfig()
192
    {
193
        return $this->customServerConfig;
194
    }
195
196
    /**
197
     * Set the value of customServerConfig
198
     *
199
     * @param array $customServerConfig
200
     * @return $this
201
     */
202
    public function setCustomServerConfig(array $customServerConfig)
203
    {
204
        $this->customServerConfig = $customServerConfig;
205
        return $this;
206
    }
207
208
    /**
209
     * Set the value of chunkUploads
210
     *
211
     * Note: please set max file upload first if you want
212
     * to see the size limit in the description
213
     *
214
     * @param bool $chunkUploads
215
     * @return $this
216
     */
217
    public function setChunkUploads($chunkUploads)
218
    {
219
        $this->addFilePondConfig('chunkUploads', true);
0 ignored issues
show
Bug introduced by
true of type true is incompatible with the type string expected by parameter $v of LeKoala\FilePond\FilePon...ld::addFilePondConfig(). ( Ignorable by Annotation )

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

219
        $this->addFilePondConfig('chunkUploads', /** @scrutinizer ignore-type */ true);
Loading history...
220
        $this->addFilePondConfig('chunkForce', true);
221
        $this->addFilePondConfig('chunkSize', $this->computeMaxChunkSize());
222
        if ($this->isDefaultMaxFileSize()) {
223
            $this->showDescriptionSize = false;
224
        }
225
        return $this;
226
    }
227
228
    /**
229
     * Return the config applied for this field
230
     *
231
     * Typically converted to json and set in a data attribute
232
     *
233
     * @return array
234
     */
235
    public function getFilePondConfig()
236
    {
237
        $name = $this->getName();
238
        $multiple = $this->getIsMultiUpload();
239
240
        // Multi uploads need []
241
        if ($multiple && strpos($name, '[]') === false) {
242
            $name .= '[]';
243
            $this->setName($name);
244
        }
245
246
        $i18nConfig = [
247
            'labelIdle' => _t('FilePondField.labelIdle', 'Drag & Drop your files or <span class="filepond--label-action"> Browse </span>'),
248
            'labelFileProcessing' => _t('FilePondField.labelFileProcessing', 'Uploading'),
249
            'labelFileProcessingComplete' => _t('FilePondField.labelFileProcessingComplete', 'Upload complete'),
250
            'labelFileProcessingAborted' => _t('FilePondField.labelFileProcessingAborted', 'Upload cancelled'),
251
            'labelTapToCancel' => _t('FilePondField.labelTapToCancel', 'tap to cancel'),
252
            'labelTapToRetry' => _t('FilePondField.labelTapToCancel', 'tap to retry'),
253
            'labelTapToUndo' => _t('FilePondField.labelTapToCancel', 'tap to undo'),
254
        ];
255
        $config = [
256
            'name' => $name, // This will also apply to the hidden fields
257
            'allowMultiple' => $multiple,
258
            'maxFiles' => $this->getAllowedMaxFileNumber(),
259
            'maxFileSize' => $this->getMaxFileSize(),
260
            'server' => $this->getServerOptions(),
261
            'files' => $this->getExistingUploadsData(),
262
        ];
263
264
        $acceptedFileTypes = $this->getAcceptedFileTypes();
265
        if (!empty($acceptedFileTypes)) {
266
            $config['acceptedFileTypes'] = array_values($acceptedFileTypes);
267
        }
268
269
        // image validation
270
        $record = $this->getForm()->getRecord();
271
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
272
            $sizes = $record->config()->image_sizes;
273
            $name = $this->getSafeName();
274
            if ($sizes && isset($sizes[$name])) {
275
                if (isset($sizes[$name][2]) && $sizes[$name][2] == 'max') {
276
                    $config['imageValidateSizeMaxWidth'] = $sizes[$name][0];
277
                    $config['imageValidateSizeMaxHeight'] = $sizes[$name][1];
278
                } else {
279
                    $config['imageValidateSizeMinWidth'] = $sizes[$name][0];
280
                    $config['imageValidateSizeMinHeight'] = $sizes[$name][1];
281
                }
282
            }
283
        }
284
285
        // image poster
286
        // @link https://pqina.nl/filepond/docs/api/plugins/file-poster/#usage
287
        if (self::config()->enable_poster) {
288
            $config['filePosterHeight'] = self::config()->poster_height ?? 264;
289
        }
290
291
        $config = array_merge($config, $i18nConfig, $this->filePondConfig);
292
293
        return $config;
294
    }
295
296
    /**
297
     * Compute best size for chunks based on server settings
298
     *
299
     * @return int
300
     */
301
    protected function computeMaxChunkSize()
302
    {
303
        $maxUpload = Convert::memstring2bytes(ini_get('upload_max_filesize'));
304
        $maxPost = Convert::memstring2bytes(ini_get('post_max_size'));
305
306
        // ~90%, allow some overhead
307
        return round(min($maxUpload, $maxPost) * 0.9);
308
    }
309
310
    /**
311
     * @inheritDoc
312
     */
313
    public function setValue($value, $record = null)
314
    {
315
        // Normalize values to something similar to UploadField usage
316
        if (is_numeric($value)) {
317
            $value = ['Files' => [$value]];
318
        } elseif (is_array($value) && empty($value['Files'])) {
319
            $value = ['Files' => $value];
320
        }
321
        // Track existing record data
322
        if ($record) {
323
            $name = $this->name;
324
            if ($record instanceof DataObject && $record->hasMethod($name)) {
325
                $data = $record->$name();
326
                // Wrap
327
                if ($data instanceof DataObject) {
328
                    $data = new ArrayList([$data]);
329
                }
330
                foreach ($data as $uploadedItem) {
331
                    $this->trackFileID($uploadedItem->ID);
332
                }
333
            }
334
        }
335
        return parent::setValue($value, $record);
336
    }
337
338
    /**
339
     * Configure our endpoint
340
     *
341
     * @link https://pqina.nl/filepond/docs/patterns/api/server/
342
     * @return array
343
     */
344
    public function getServerOptions()
345
    {
346
        if ($this->customServerConfig) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->customServerConfig of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
347
            return $this->customServerConfig;
348
        }
349
        if (!$this->getForm()) {
350
            throw new LogicException(
351
                'Field must be associated with a form to call getServerOptions(). Please use $field->setForm($form);'
352
            );
353
        }
354
        $endpoint = $this->getChunkUploads() ? 'chunk' : 'upload';
355
        $server = [
356
            'process' => $this->getUploadEnabled() ? $this->getLinkParameters($endpoint) : null,
357
            'fetch' => null,
358
            'revert' => $this->getUploadEnabled() ? $this->getLinkParameters('revert') : null,
359
        ];
360
        if ($this->getUploadEnabled() && $this->getChunkUploads()) {
361
            $server['fetch'] =  $this->getLinkParameters($endpoint . "?fetch=");
362
            $server['patch'] =  $this->getLinkParameters($endpoint . "?patch=");
363
        }
364
        return $server;
365
    }
366
367
    /**
368
     * Configure the following parameters:
369
     *
370
     * url : Path to the end point
371
     * method : Request method to use
372
     * withCredentials : Toggles the XMLHttpRequest withCredentials on or off
373
     * headers : An object containing additional headers to send
374
     * timeout : Timeout for this action
375
     * onload : Called when server response is received, useful for getting the unique file id from the server response
376
     * onerror : Called when server error is received, receis the response body, useful to select the relevant error data
377
     *
378
     * @param string $action
379
     * @return array
380
     */
381
    protected function getLinkParameters($action)
382
    {
383
        $form = $this->getForm();
384
        $token = $form->getSecurityToken()->getValue();
385
        $record = $form->getRecord();
386
387
        $headers = [
388
            'X-SecurityID' => $token
389
        ];
390
        // Allow us to track the record instance
391
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
392
            $headers['X-RecordClassName'] = get_class($record);
393
            $headers['X-RecordID'] = $record->ID;
394
        }
395
        return [
396
            'url' => $this->Link($action),
397
            'headers' => $headers,
398
        ];
399
    }
400
401
    /**
402
     * The maximum size of a file, for instance 5MB or 750KB
403
     * Suitable for JS usage
404
     *
405
     * @return string
406
     */
407
    public function getMaxFileSize()
408
    {
409
        return str_replace(' ', '', File::format_size($this->getValidator()->getAllowedMaxFileSize()));
410
    }
411
412
    /**
413
     * Set initial values to FilePondField
414
     * See: https://pqina.nl/filepond/docs/patterns/api/filepond-object/#setting-initial-files
415
     *
416
     * @return array
417
     */
418
    public function getExistingUploadsData()
419
    {
420
        // Both Value() & dataValue() seem to return an array eg: ['Files' => [258, 259, 257]]
421
        $fileIDarray = $this->Value() ?: ['Files' => []];
422
        if (!isset($fileIDarray['Files']) || !count($fileIDarray['Files'])) {
423
            return [];
424
        }
425
426
        $existingUploads = [];
427
        foreach ($fileIDarray['Files'] as $fileID) {
428
            /* @var $file File */
429
            $file = File::get()->byID($fileID);
430
            if (!$file) {
431
                continue;
432
            }
433
            $existingUpload = [
434
                // the server file reference
435
                'source' => (int) $fileID,
436
                // set type to local to indicate an already uploaded file
437
                'options' => [
438
                    'type' => 'local',
439
                    // file information
440
                    'file' => [
441
                        'name' => $file->Name,
442
                        'size' => (int) $file->getAbsoluteSize(),
443
                        'type' => $file->getMimeType(),
444
                    ],
445
                ],
446
                'metadata' => []
447
            ];
448
449
            // Show poster
450
            // @link https://pqina.nl/filepond/docs/api/plugins/file-poster/#usage
451
            if (self::config()->enable_poster && $file instanceof Image) {
452
                // Size matches the one from asset admin
453
                $w = self::config()->poster_width ?? 352;
454
                $h = self::config()->poster_height ?? 264;
455
                $poster = $file->Fill($w, $h)->getAbsoluteURL();
456
                $existingUpload['options']['metadata']['poster'] = $poster;
457
            }
458
            $existingUploads[] = $existingUpload;
459
        }
460
        return $existingUploads;
461
    }
462
463
    /**
464
     * Requirements are NOT versioned since filepond is regularly updated
465
     *
466
     * @return void
467
     */
468
    public static function Requirements()
469
    {
470
        $baseDir = "https://cdn.jsdelivr.net/gh/pqina/";
471
        if (!self::config()->use_cdn) {
472
            $asset = ModuleResourceLoader::resourceURL('lekoala/silverstripe-filepond:javascript/FilePondField.js');
473
            $baseDir = dirname($asset) . "/cdn";
474
        }
475
476
        // Polyfill to ensure max compatibility
477
        if (self::config()->enable_polyfill) {
478
            Requirements::javascript("$baseDir/filepond-polyfill/dist/filepond-polyfill.min.js");
479
        }
480
481
        // File/image validation plugins
482
        if (self::config()->enable_validation) {
483
            Requirements::javascript("$baseDir/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.min.js");
484
            Requirements::javascript("$baseDir/filepond-plugin-file-validate-size/dist/filepond-plugin-file-validate-size.min.js");
485
            Requirements::javascript("$baseDir/filepond-plugin-image-validate-size/dist/filepond-plugin-image-validate-size.js");
486
        }
487
488
        // Poster plugins
489
        if (self::config()->enable_poster) {
490
            Requirements::javascript("$baseDir/filepond-plugin-file-metadata/dist/filepond-plugin-file-metadata.min.js");
491
            Requirements::css("$baseDir/filepond-plugin-file-poster/dist/filepond-plugin-file-poster.min.css");
492
            Requirements::javascript("$baseDir/filepond-plugin-file-poster/dist/filepond-plugin-file-poster.min.js");
493
        }
494
495
        // Image plugins
496
        if (self::config()->enable_image) {
497
            Requirements::javascript("$baseDir/filepond-plugin-image-exif-orientation/dist/filepond-plugin-image-exif-orientation.js");
498
            Requirements::css("$baseDir/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css");
499
            Requirements::javascript("$baseDir/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.js");
500
        }
501
502
        // Base elements
503
        Requirements::css("$baseDir/filepond/dist/filepond.css");
504
        Requirements::javascript("$baseDir/filepond/dist/filepond.js");
505
506
        // Our custom init
507
        Requirements::javascript('lekoala/silverstripe-filepond:javascript/FilePondField.js');
508
509
        // In the cms, init will not be triggered
510
        if (self::config()->enable_ajax_init && Director::is_ajax()) {
511
            Requirements::javascript('lekoala/silverstripe-filepond:javascript/FilePondField-init.js?t=' . time());
512
        }
513
    }
514
515
    public function FieldHolder($properties = array())
516
    {
517
        $config = $this->getFilePondConfig();
518
519
        $this->setAttribute('data-config', json_encode($config));
520
521
        if (self::config()->enable_requirements) {
522
            self::Requirements();
523
        }
524
525
        return parent::FieldHolder($properties);
526
    }
527
528
    /**
529
     * Check the incoming request
530
     *
531
     * @param HTTPRequest $request
532
     * @return array
533
     */
534
    public function prepareUpload(HTTPRequest $request)
535
    {
536
        $name = $this->getName();
537
        $tmpFile = $request->postVar($name);
538
        if (!$tmpFile) {
539
            throw new RuntimeException("No file");
540
        }
541
        $tmpFile = $this->normalizeTempFile($tmpFile);
542
543
        // Update $tmpFile with a better name
544
        if ($this->renamePattern) {
545
            $tmpFile['name'] = $this->changeFilenameWithPattern(
546
                $tmpFile['name'],
547
                $this->renamePattern
548
            );
549
        }
550
551
        return $tmpFile;
552
    }
553
554
    /**
555
     * @param HTTPRequest $request
556
     * @return void
557
     */
558
    protected function securityChecks(HTTPRequest $request)
559
    {
560
        if ($this->isDisabled() || $this->isReadonly()) {
561
            throw new RuntimeException("Field is disabled or readonly");
562
        }
563
564
        // CSRF check
565
        $token = $this->getForm()->getSecurityToken();
566
        if (!$token->checkRequest($request)) {
567
            throw new RuntimeException("Invalid token");
568
        }
569
    }
570
571
    /**
572
     * @param File $file
573
     * @param HTTPRequest $request
574
     * @return void
575
     */
576
    protected function setFileDetails(File $file, HTTPRequest $request)
577
    {
578
        // Mark as temporary until properly associated with a record
579
        // Files will be unmarked later on by saveInto method
580
        $file->IsTemporary = true;
581
582
        // We can also track the record
583
        $RecordID = $request->getHeader('X-RecordID');
584
        $RecordClassName = $request->getHeader('X-RecordClassName');
585
        if (!$file->ObjectID) {
586
            $file->ObjectID = $RecordID;
587
        }
588
        if (!$file->ObjectClass) {
589
            $file->ObjectClass = $RecordClassName;
590
        }
591
592
        if ($file->isChanged()) {
593
            // If possible, prevent creating a version for no reason
594
            // @link https://docs.silverstripe.org/en/4/developer_guides/model/versioning/#writing-changes-to-a-versioned-dataobject
595
            if ($file->hasExtension(Versioned::class)) {
596
                $file->writeWithoutVersion();
597
            } else {
598
                $file->write();
599
            }
600
        }
601
    }
602
603
    /**
604
     * Creates a single file based on a form-urlencoded upload.
605
     *
606
     * 1 client uploads file my-file.jpg as multipart/form-data using a POST request
607
     * 2 server saves file to unique location tmp/12345/my-file.jpg
608
     * 3 server returns unique location id 12345 in text/plain response
609
     * 4 client stores unique id 12345 in a hidden input field
610
     * 5 client submits the FilePond parent form containing the hidden input field with the unique id
611
     * 6 server uses the unique id to move tmp/12345/my-file.jpg to its final location and remove the tmp/12345 folder
612
     *
613
     * Along with the file object, FilePond also sends the file metadata to the server, both these objects are given the same name.
614
     *
615
     * @param HTTPRequest $request
616
     * @return HTTPResponse
617
     */
618
    public function upload(HTTPRequest $request)
619
    {
620
        try {
621
            $this->securityChecks($request);
622
            $tmpFile = $this->prepareUpload($request);
623
        } catch (Exception $ex) {
624
            return $this->httpError(400, $ex->getMessage());
625
        }
626
627
        $file = $this->saveTemporaryFile($tmpFile, $error);
628
629
        // Handle upload errors
630
        if ($error) {
631
            $this->getUpload()->clearErrors();
632
            return $this->httpError(400, json_encode($error));
633
        }
634
635
        // File can be an AssetContainer and not a DataObject
636
        if ($file instanceof DataObject) {
637
            $this->setFileDetails($file, $request);
638
        }
639
640
        $this->getUpload()->clearErrors();
641
        $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...
642
        $this->trackFileID($fileId);
643
644
        if (self::config()->auto_clear_temp_folder) {
645
            // Set a limit of 100 because otherwise it would be really slow
646
            self::clearTemporaryUploads(true, 100);
647
        }
648
649
        // server returns unique location id 12345 in text/plain response
650
        $response = new HTTPResponse($fileId);
651
        $response->addHeader('Content-Type', 'text/plain');
652
        return $response;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $response returns the type SilverStripe\Control\HTTPResponse which is incompatible with the return type mandated by LeKoala\FilePond\AbstractUploadField::upload() of LeKoala\FilePond\HTTPResponse.

In the issue above, the returned value is violating the contract defined by the mentioned interface.

Let's take a look at an example:

interface HasName {
    /** @return string */
    public function getName();
}

class Name {
    public $name;
}

class User implements HasName {
    /** @return string|Name */
    public function getName() {
        return new Name('foo'); // This is a violation of the ``HasName`` interface
                                // which only allows a string value to be returned.
    }
}
Loading history...
653
    }
654
655
    /**
656
     * @link https://pqina.nl/filepond/docs/api/server/#process-chunks
657
     * @param HTTPRequest $request
658
     * @return void
659
     */
660
    public function chunk(HTTPRequest $request)
661
    {
662
        try {
663
            $this->securityChecks($request);
664
        } catch (Exception $ex) {
665
            return $this->httpError(400, $ex->getMessage());
666
        }
667
668
        $method = $request->httpMethod();
669
670
        // The random token is returned as a query string
671
        $id = $request->getVar('patch');
672
673
        // FilePond will send a POST request (without file) to start a chunked transfer,
674
        // expecting to receive a unique transfer id in the response body, it'll add the Upload-Length header to this request.
675
        if ($method == "POST") {
676
            // Initial post payload doesn't contain name
677
            // It would be better to return some kind of random token instead
678
            // But FilePond stores the id upon the first request :-(
679
            $file = new File();
680
            $this->setFileDetails($file, $request);
681
            $fileId = $file->ID;
682
            $this->trackFileID($fileId);
683
            $response = new HTTPResponse($fileId, 200);
684
            $response->addHeader('Content-Type', 'text/plain');
685
            return $response;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $response returns the type SilverStripe\Control\HTTPResponse which is incompatible with the documented return type void.
Loading history...
686
        }
687
688
        // FilePond will send a HEAD request to determine which chunks have already been uploaded,
689
        // expecting the file offset of the next expected chunk in the Upload-Offset response header.
690
        if ($method == "HEAD") {
691
            $nextOffset = 0;
692
693
            //TODO: iterate over temp files and check next offset
694
            $response = new HTTPResponse($nextOffset, 200);
695
            $response->addHeader('Content-Type', 'text/plain');
696
            return $response;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $response returns the type SilverStripe\Control\HTTPResponse which is incompatible with the documented return type void.
Loading history...
697
        }
698
699
        // FilePond will send a PATCH request to push a chunk to the server.
700
        // Each of these requests is accompanied by a Content-Type, Upload-Offset, Upload-Name, and Upload-Length header.
701
        if ($method != "PATCH") {
702
            return $this->httpError(400, "Invalid method");
703
        }
704
705
        // The name of the file being transferred
706
        $uploadName = $request->getHeader('Upload-Name');
707
        // The total size of the file being transferred
708
        $offset = $request->getHeader('Upload-Offset');
709
        // The offset of the chunk being transferred
710
        $length = $request->getHeader('Upload-Length');
711
712
        // location of patch files
713
        $filePath = TEMP_PATH . "/filepond-" . $id;
714
715
        // should be numeric values, else exit
716
        if (!is_numeric($offset) || !is_numeric($length)) {
717
            return $this->httpError(400, "Invalid offset or length");
718
        }
719
720
        // write patch file for this request
721
        file_put_contents($filePath . '.patch.' . $offset, $request->getBody());
722
723
        // calculate total size of patches
724
        $size = 0;
725
        $patch = glob($filePath . '.patch.*');
726
        foreach ($patch as $filename) {
727
            $size += filesize($filename);
728
        }
729
        // if total size equals length of file we have gathered all patch files
730
        if ($size >= $length) {
731
            // create output file
732
            $outputFile = fopen($filePath, 'wb');
733
            // write patches to file
734
            foreach ($patch as $filename) {
735
                // get offset from filename
736
                list($dir, $offset) = explode('.patch.', $filename, 2);
737
                // read patch and close
738
                $patchFile = fopen($filename, 'rb');
739
                $patchContent = fread($patchFile, filesize($filename));
740
                fclose($patchFile);
741
742
                // apply patch
743
                fseek($outputFile, $offset);
0 ignored issues
show
Bug introduced by
$offset of type string is incompatible with the type integer expected by parameter $offset of fseek(). ( Ignorable by Annotation )

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

743
                fseek($outputFile, /** @scrutinizer ignore-type */ $offset);
Loading history...
744
                fwrite($outputFile, $patchContent);
745
            }
746
            // remove patches
747
            foreach ($patch as $filename) {
748
                unlink($filename);
749
            }
750
            // done with file
751
            fclose($outputFile);
752
753
            // Finalize real filename
754
            $realFilename = $this->getFolderName() . "/" . $uploadName;
755
            if ($this->renamePattern) {
756
                $realFilename = $this->changeFilenameWithPattern(
757
                    $realFilename,
758
                    $this->renamePattern
759
                );
760
            }
761
762
            // write output file to asset store
763
            $file = $this->getFileByID($id);
764
            if (!$file) {
765
                return $this->httpError("File $id not found");
0 ignored issues
show
Bug introduced by
'File '.$id.' not found' of type string is incompatible with the type integer expected by parameter $errorCode of SilverStripe\Control\RequestHandler::httpError(). ( Ignorable by Annotation )

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

765
                return $this->httpError(/** @scrutinizer ignore-type */ "File $id not found");
Loading history...
766
            }
767
            $file->setFromLocalFile($filePath);
768
            $file->setFilename($realFilename);
769
            $file->Title = $uploadName;
770
            // Is an image ?
771
            $imageExtensions = File::get_category_extensions('image/supported');
772
            if (in_array(pathinfo($realFilename, PATHINFO_EXTENSION), $imageExtensions)) {
773
                $file->setClassName(Image::class);
774
            }
775
            $file->write();
776
            if (in_array(pathinfo($realFilename, PATHINFO_EXTENSION), $imageExtensions)) {
777
                // Reload file instance
778
                $file = Image::get()->byID($id);
779
            }
780
            // since we don't go through our upload object, call extension manually
781
            $file->extend('onAfterUpload');
782
        }
783
        $response = new HTTPResponse('', 204);
784
        return $response;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $response returns the type SilverStripe\Control\HTTPResponse which is incompatible with the documented return type void.
Loading history...
785
    }
786
787
    /**
788
     * @link https://pqina.nl/filepond/docs/api/server/#revert
789
     * @param HTTPRequest $request
790
     * @return void
791
     */
792
    public function revert(HTTPRequest $request)
793
    {
794
        try {
795
            $this->securityChecks($request);
796
        } catch (Exception $ex) {
797
            return $this->httpError(400, $ex->getMessage());
798
        }
799
800
        $method = $request->httpMethod();
801
802
        if ($method != "DELETE") {
803
            return $this->httpError(400, "Invalid method");
804
        }
805
806
        $fileID = (int) $request->getBody();
807
        if (!in_array($fileID, $this->getTrackedIDs())) {
808
            return $this->httpError(400, "Invalid ID");
809
        }
810
        $file = File::get()->byID($fileID);
811
        if (!$file->IsTemporary) {
812
            return $this->httpError(400, "Invalid file");
813
        }
814
        if (!$file->canDelete()) {
815
            return $this->httpError(400, "Cannot delete file");
816
        }
817
        $file->delete();
818
        $response = new HTTPResponse('', 200);
819
        return $response;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $response returns the type SilverStripe\Control\HTTPResponse which is incompatible with the documented return type void.
Loading history...
820
    }
821
822
    /**
823
     * Clear temp folder that should not contain any file other than temporary
824
     *
825
     * @param boolean $doDelete Set to true to actually delete the files, otherwise it's just a dry-run
826
     * @param int $limit
827
     * @return File[] List of files removed
828
     */
829
    public static function clearTemporaryUploads($doDelete = false, $limit = 0)
830
    {
831
        $tempFiles = File::get()->filter('IsTemporary', true);
832
        if ($limit) {
833
            $tempFiles = $tempFiles->limit($limit);
834
        }
835
836
        $threshold = self::config()->auto_clear_threshold;
837
838
        // Set a default threshold if none set
839
        if (!$threshold) {
840
            if (Director::isDev()) {
841
                $threshold = '-10 minutes';
842
            } else {
843
                $threshold = '-1 day';
844
            }
845
        }
846
        if (is_int($threshold)) {
847
            $thresholdTime = time() - $threshold;
848
        } else {
849
            $thresholdTime = strtotime($threshold);
850
        }
851
852
        // Update query to avoid fetching unecessary records
853
        $tempFiles = $tempFiles->filter("Created:LessThan", date('Y-m-d H:i:s', $thresholdTime));
854
855
        $filesDeleted = [];
856
        foreach ($tempFiles as $tempFile) {
857
            $createdTime = strtotime($tempFile->Created);
858
            if ($createdTime < $thresholdTime) {
859
                $filesDeleted[] = $tempFile;
860
                if ($doDelete) {
861
                    if ($tempFile->hasExtension(Versioned::class)) {
862
                        $tempFile->deleteFromStage(Versioned::LIVE);
863
                        $tempFile->deleteFromStage(Versioned::DRAFT);
864
                    } else {
865
                        $tempFile->delete();
866
                    }
867
                }
868
            }
869
        }
870
        return $filesDeleted;
871
    }
872
873
    /**
874
     * Allows tracking uploaded ids to prevent unauthorized attachements
875
     *
876
     * @param int $fileId
877
     * @return void
878
     */
879
    public function trackFileID($fileId)
880
    {
881
        $session = $this->getRequest()->getSession();
882
        $uploadedIDs = $this->getTrackedIDs();
883
        if (!in_array($fileId, $uploadedIDs)) {
884
            $uploadedIDs[] = $fileId;
885
        }
886
        $session->set('FilePond', $uploadedIDs);
887
    }
888
889
    /**
890
     * Get all authorized tracked ids
891
     * @return array
892
     */
893
    public function getTrackedIDs()
894
    {
895
        $session = $this->getRequest()->getSession();
896
        $uploadedIDs = $session->get('FilePond');
897
        if ($uploadedIDs) {
898
            return $uploadedIDs;
899
        }
900
        return [];
901
    }
902
903
    public function saveInto(DataObjectInterface $record)
904
    {
905
        // Note that the list of IDs is based on the value sent by the user
906
        // It can be spoofed because checks are minimal (by default, canView = true and only check if isInDB)
907
        $IDs = $this->getItemIDs();
908
909
        $Member = Security::getCurrentUser();
910
911
        // Ensure the files saved into the DataObject have been tracked (either because already on the DataObject or uploaded by the user)
912
        $trackedIDs = $this->getTrackedIDs();
913
        foreach ($IDs as $ID) {
914
            if (!in_array($ID, $trackedIDs)) {
915
                throw new ValidationException("Invalid file ID : $ID");
916
            }
917
        }
918
919
        // Move files out of temporary folder
920
        foreach ($IDs as $ID) {
921
            $file = $this->getFileByID($ID);
922
            if ($file && $file->IsTemporary) {
923
                // The record does not have an ID which is a bad idea to attach the file to it
924
                if (!$record->ID) {
925
                    $record->write();
926
                }
927
                // Check if the member is owner
928
                if ($Member && $Member->ID != $file->OwnerID) {
929
                    throw new ValidationException("Failed to authenticate owner");
930
                }
931
                $file->IsTemporary = false;
932
                $file->ObjectID = $record->ID;
933
                $file->ObjectClass = get_class($record);
934
                $file->write();
935
            } else {
936
                // File was uploaded earlier, no need to do anything
937
            }
938
        }
939
940
        // Proceed
941
        return parent::saveInto($record);
942
    }
943
944
    public function Type()
945
    {
946
        return 'filepond';
947
    }
948
}
949