Passed
Push — master ( 10130d...d814e3 )
by Thomas
02:12
created

FilePondField   F

Complexity

Total Complexity 105

Size/Duplication

Total Lines 818
Duplicated Lines 0 %

Importance

Changes 8
Bugs 1 Features 1
Metric Value
eloc 317
c 8
b 1
f 1
dl 0
loc 818
rs 2
wmc 105

23 Methods

Rating   Name   Duplication   Size   Complexity  
B clearTemporaryUploads() 0 42 9
A getLinkParameters() 0 17 2
A getCustomFilePondConfig() 0 3 1
A getChunkUploads() 0 3 1
A getTrackedIDs() 0 8 2
A addFilePondConfig() 0 4 1
B getExistingUploadsData() 0 43 8
A upload() 0 35 5
A getMaxFileSize() 0 3 1
B setValue() 0 23 9
A prepareUpload() 0 18 3
A FieldHolder() 0 11 2
B getFilePondConfig() 0 63 11
C chunk() 0 111 13
A setChunkUploads() 0 4 1
A trackFileID() 0 8 2
B Requirements() 0 44 8
A getServerOptions() 0 18 5
B saveInto() 0 39 9
A __construct() 0 6 2
A Type() 0 3 1
A securityChecks() 0 10 4
A setFileDetails() 0 23 5

How to fix   Complexity   

Complex Class

Complex classes like FilePondField often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use FilePondField, and based on these observations, apply Extract Interface, too.

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\ORM\ArrayList;
12
use SilverStripe\ORM\DataObject;
13
use SilverStripe\Security\Member;
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
30
    /**
31
     * @config
32
     * @var array
33
     */
34
    private static $allowed_actions = [
0 ignored issues
show
introduced by
The private property $allowed_actions is not used, and could be removed.
Loading history...
35
        'upload',
36
        'chunk',
37
    ];
38
39
    /**
40
     * @config
41
     * @var boolean
42
     */
43
    private static $enable_requirements = true;
44
45
    /**
46
     * @config
47
     * @var boolean
48
     */
49
    private static $enable_validation = true;
50
51
    /**
52
     * @config
53
     * @var boolean
54
     */
55
    private static $enable_poster = false;
56
57
    /**
58
     * @config
59
     * @var boolean
60
     */
61
    private static $enable_image = false;
62
63
    /**
64
     * @config
65
     * @var boolean
66
     */
67
    private static $enable_polyfill = true;
68
69
    /**
70
     * @config
71
     * @var boolean
72
     */
73
    private static $enable_ajax_init = true;
74
75
    /**
76
     * @config
77
     * @var boolean
78
     */
79
    private static $chunk_by_default = false;
80
81
    /**
82
     * @config
83
     * @var boolean
84
     */
85
    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...
86
87
    /**
88
     * @config
89
     * @var boolean
90
     */
91
    private static $auto_clear_temp_folder = true;
92
93
    /**
94
     * @config
95
     * @var int
96
     */
97
    private static $auto_clear_threshold = true;
98
99
    /**
100
     * @config
101
     * @var boolean
102
     */
103
    private static $use_cdn = true;
104
105
    /**
106
     * @config
107
     * @var int
108
     */
109
    private static $poster_width = 352;
110
111
    /**
112
     * @config
113
     * @var int
114
     */
115
    private static $poster_height = 264;
116
117
    /**
118
     * @var array
119
     */
120
    protected $filePondConfig = [];
121
122
    /**
123
     * @var bool
124
     */
125
    protected $chunkUploads = false;
126
127
    /**
128
     * Create a new file field.
129
     *
130
     * @param string $name The internal field name, passed to forms.
131
     * @param string $title The field label.
132
     * @param SS_List $items Items assigned to this field
133
     */
134
    public function __construct($name, $title = null, SS_List $items = null)
135
    {
136
        parent::__construct($name, $title, $items);
137
138
        if (self::config()->chunk_by_default) {
139
            $this->setChunkUploads(true);
140
        }
141
    }
142
143
    /**
144
     * Set a custom config value for this field
145
     *
146
     * @link https://pqina.nl/filepond/docs/patterns/api/filepond-instance/#properties
147
     * @param string $k
148
     * @param string $v
149
     * @return $this
150
     */
151
    public function addFilePondConfig($k, $v)
152
    {
153
        $this->filePondConfig[$k] = $v;
154
        return $this;
155
    }
156
157
    /**
158
     * Custom configuration applied to this field
159
     *
160
     * @return array
161
     */
162
    public function getCustomFilePondConfig()
163
    {
164
        return $this->filePondConfig;
165
    }
166
    /**
167
     * Get the value of chunkUploads
168
     * @return bool
169
     */
170
    public function getChunkUploads()
171
    {
172
        return $this->chunkUploads;
173
    }
174
175
    /**
176
     * Set the value of chunkUploads
177
     *
178
     * @param bool $chunkUploads
179
     * @return $this
180
     */
181
    public function setChunkUploads($chunkUploads)
182
    {
183
        $this->chunkUploads = $chunkUploads;
184
        return $this;
185
    }
186
187
    /**
188
     * Return the config applied for this field
189
     *
190
     * Typically converted to json and set in a data attribute
191
     *
192
     * @return array
193
     */
194
    public function getFilePondConfig()
195
    {
196
        $name = $this->getName();
197
        $multiple = $this->getIsMultiUpload();
198
199
        // Multi uploads need []
200
        if ($multiple && strpos($name, '[]') === false) {
201
            $name .= '[]';
202
            $this->setName($name);
203
        }
204
205
        $i18nConfig = [
206
            'labelIdle' => _t('FilePondField.labelIdle', 'Drag & Drop your files or <span class="filepond--label-action"> Browse </span>'),
207
            'labelFileProcessing' => _t('FilePondField.labelFileProcessing', 'Uploading'),
208
            'labelFileProcessingComplete' => _t('FilePondField.labelFileProcessingComplete', 'Upload complete'),
209
            'labelFileProcessingAborted' => _t('FilePondField.labelFileProcessingAborted', 'Upload cancelled'),
210
            'labelTapToCancel' => _t('FilePondField.labelTapToCancel', 'tap to cancel'),
211
            'labelTapToRetry' => _t('FilePondField.labelTapToCancel', 'tap to retry'),
212
            'labelTapToUndo' => _t('FilePondField.labelTapToCancel', 'tap to undo'),
213
        ];
214
        $config = [
215
            'name' => $name, // This will also apply to the hidden fields
216
            'allowMultiple' => $multiple,
217
            'maxFiles' => $this->getAllowedMaxFileNumber(),
218
            'maxFileSize' => $this->getMaxFileSize(),
219
            'server' => $this->getServerOptions(),
220
            'files' => $this->getExistingUploadsData(),
221
        ];
222
        if ($this->chunkUploads) {
223
            $config['chunkUploads'] = true;
224
            $config['chunkForce'] = true;
225
        }
226
227
        $acceptedFileTypes = $this->getAcceptedFileTypes();
228
        if (!empty($acceptedFileTypes)) {
229
            $config['acceptedFileTypes'] = array_values($acceptedFileTypes);
230
        }
231
232
        // image validation
233
        $record = $this->getForm()->getRecord();
234
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
235
            $sizes = $record->config()->image_sizes;
236
            $name = $this->getSafeName();
237
            if ($sizes && isset($sizes[$name])) {
238
                if (isset($sizes[$name][2]) && $sizes[$name][2] == 'max') {
239
                    $config['imageValidateSizeMaxWidth'] = $sizes[$name][0];
240
                    $config['imageValidateSizeMaxHeight'] = $sizes[$name][1];
241
                } else {
242
                    $config['imageValidateSizeMinWidth'] = $sizes[$name][0];
243
                    $config['imageValidateSizeMinHeight'] = $sizes[$name][1];
244
                }
245
            }
246
        }
247
248
        // image poster
249
        // @link https://pqina.nl/filepond/docs/api/plugins/file-poster/#usage
250
        if (self::config()->enable_poster) {
251
            $config['filePosterHeight'] = self::config()->poster_height ?? 264;
252
        }
253
254
        $config = array_merge($config, $i18nConfig, $this->filePondConfig);
255
256
        return $config;
257
    }
258
259
    /**
260
     * @inheritDoc
261
     */
262
    public function setValue($value, $record = null)
263
    {
264
        // Normalize values to something similar to UploadField usage
265
        if (is_numeric($value)) {
266
            $value = ['Files' => [$value]];
267
        } elseif (is_array($value) && empty($value['Files'])) {
268
            $value = ['Files' => $value];
269
        }
270
        // Track existing record data
271
        if ($record) {
272
            $name = $this->name;
273
            if ($record instanceof DataObject && $record->hasMethod($name)) {
274
                $data = $record->$name();
275
                // Wrap
276
                if ($data instanceof DataObject) {
277
                    $data = new ArrayList([$data]);
278
                }
279
                foreach ($data as $uploadedItem) {
280
                    $this->trackFileID($uploadedItem->ID);
281
                }
282
            }
283
        }
284
        return parent::setValue($value, $record);
285
    }
286
287
288
    /**
289
     * Configure our endpoint
290
     *
291
     * @link https://pqina.nl/filepond/docs/patterns/api/server/
292
     * @return array
293
     */
294
    public function getServerOptions()
295
    {
296
        if (!$this->getForm()) {
297
            throw new LogicException(
298
                'Field must be associated with a form to call getServerOptions(). Please use $field->setForm($form);'
299
            );
300
        }
301
        $endpoint = $this->chunkUploads ? 'chunk' : 'upload';
302
        $server = [
303
            'process' => $this->getUploadEnabled() ? $this->getLinkParameters($endpoint) : null,
304
            'fetch' => null,
305
            'revert' => null,
306
        ];
307
        if ($this->chunkUploads) {
308
            $server['fetch'] =  $this->getLinkParameters($endpoint . "?fetch=");
309
            $server['patch'] =  $this->getLinkParameters($endpoint . "?patch=");
310
        }
311
        return $server;
312
    }
313
314
    /**
315
     * Configure the following parameters:
316
     *
317
     * url : Path to the end point
318
     * method : Request method to use
319
     * withCredentials : Toggles the XMLHttpRequest withCredentials on or off
320
     * headers : An object containing additional headers to send
321
     * timeout : Timeout for this action
322
     * onload : Called when server response is received, useful for getting the unique file id from the server response
323
     * onerror : Called when server error is received, receis the response body, useful to select the relevant error data
324
     *
325
     * @param string $action
326
     * @return array
327
     */
328
    protected function getLinkParameters($action)
329
    {
330
        $form = $this->getForm();
331
        $token = $form->getSecurityToken()->getValue();
332
        $record = $form->getRecord();
333
334
        $headers = [
335
            'X-SecurityID' => $token
336
        ];
337
        // Allow us to track the record instance
338
        if ($record) {
0 ignored issues
show
introduced by
$record is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
339
            $headers['X-RecordClassName'] = get_class($record);
340
            $headers['X-RecordID'] = $record->ID;
341
        }
342
        return [
343
            'url' => $this->Link($action),
344
            'headers' => $headers,
345
        ];
346
    }
347
348
    /**
349
     * The maximum size of a file, for instance 5MB or 750KB
350
     * Suitable for JS usage
351
     *
352
     * @return string
353
     */
354
    public function getMaxFileSize()
355
    {
356
        return str_replace(' ', '', File::format_size($this->getValidator()->getAllowedMaxFileSize()));
357
    }
358
359
    /**
360
     * Set initial values to FilePondField
361
     * See: https://pqina.nl/filepond/docs/patterns/api/filepond-object/#setting-initial-files
362
     *
363
     * @return array
364
     */
365
    public function getExistingUploadsData()
366
    {
367
        // Both Value() & dataValue() seem to return an array eg: ['Files' => [258, 259, 257]]
368
        $fileIDarray = $this->Value() ?: ['Files' => []];
369
        if (!isset($fileIDarray['Files']) || !count($fileIDarray['Files'])) {
370
            return [];
371
        }
372
373
        $existingUploads = [];
374
        foreach ($fileIDarray['Files'] as $fileID) {
375
            /* @var $file File */
376
            $file = File::get()->byID($fileID);
377
            if (!$file) {
378
                continue;
379
            }
380
            $existingUpload = [
381
                // the server file reference
382
                'source' => (int) $fileID,
383
                // set type to local to indicate an already uploaded file
384
                'options' => [
385
                    'type' => 'local',
386
                    // file information
387
                    'file' => [
388
                        'name' => $file->Name,
389
                        'size' => (int) $file->getAbsoluteSize(),
390
                        'type' => $file->getMimeType(),
391
                    ],
392
                ],
393
                'metadata' => []
394
            ];
395
396
            // Show poster
397
            // @link https://pqina.nl/filepond/docs/api/plugins/file-poster/#usage
398
            if (self::config()->enable_poster && $file instanceof Image) {
399
                // Size matches the one from asset admin
400
                $w = self::config()->poster_width ?? 352;
401
                $h = self::config()->poster_height ?? 264;
402
                $poster = $file->Fill($w, $h)->getAbsoluteURL();
403
                $existingUpload['options']['metadata']['poster'] = $poster;
404
            }
405
            $existingUploads[] = $existingUpload;
406
        }
407
        return $existingUploads;
408
    }
409
410
    /**
411
     * Requirements are NOT versioned since filepond is regularly updated
412
     *
413
     * @return void
414
     */
415
    public static function Requirements()
416
    {
417
        $baseDir = "https://cdn.jsdelivr.net/gh/pqina/";
418
        if (!self::config()->use_cdn) {
419
            $asset = ModuleResourceLoader::resourceURL('lekoala/silverstripe-filepond:javascript/FilePondField.js');
420
            $baseDir = dirname($asset) . "/cdn";
421
        }
422
423
        // Polyfill to ensure max compatibility
424
        if (self::config()->enable_polyfill) {
425
            Requirements::javascript("$baseDir/filepond-polyfill/dist/filepond-polyfill.min.js");
426
        }
427
428
        // File/image validation plugins
429
        if (self::config()->enable_validation) {
430
            Requirements::javascript("$baseDir/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.min.js");
431
            Requirements::javascript("$baseDir/filepond-plugin-file-validate-size/dist/filepond-plugin-file-validate-size.min.js");
432
            Requirements::javascript("$baseDir/filepond-plugin-image-validate-size/dist/filepond-plugin-image-validate-size.js");
433
        }
434
435
        // Poster plugins
436
        if (self::config()->enable_poster) {
437
            Requirements::javascript("$baseDir/filepond-plugin-file-metadata/dist/filepond-plugin-file-metadata.min.js");
438
            Requirements::css("$baseDir/filepond-plugin-file-poster/dist/filepond-plugin-file-poster.min.css");
439
            Requirements::javascript("$baseDir/filepond-plugin-file-poster/dist/filepond-plugin-file-poster.min.js");
440
        }
441
442
        // Image plugins
443
        if (self::config()->enable_image) {
444
            Requirements::javascript("$baseDir/filepond-plugin-image-exif-orientation/dist/filepond-plugin-image-exif-orientation.js");
445
            Requirements::css("$baseDir/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css");
446
            Requirements::javascript("$baseDir/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.js");
447
        }
448
449
        // Base elements
450
        Requirements::css("$baseDir/filepond/dist/filepond.css");
451
        Requirements::javascript("$baseDir/filepond/dist/filepond.js");
452
453
        // Our custom init
454
        Requirements::javascript('lekoala/silverstripe-filepond:javascript/FilePondField.js');
455
456
        // In the cms, init will not be triggered
457
        if (self::config()->enable_ajax_init && Director::is_ajax()) {
458
            Requirements::javascript('lekoala/silverstripe-filepond:javascript/FilePondField-init.js?t=' . time());
459
        }
460
    }
461
462
    public function FieldHolder($properties = array())
463
    {
464
        $config = $this->getFilePondConfig();
465
466
        $this->setAttribute('data-config', json_encode($config));
467
468
        if (self::config()->enable_requirements) {
469
            self::Requirements();
470
        }
471
472
        return parent::FieldHolder($properties);
473
    }
474
475
    /**
476
     * Check the incoming request
477
     *
478
     * @param HTTPRequest $request
479
     * @return array
480
     */
481
    public function prepareUpload(HTTPRequest $request)
482
    {
483
        $name = $this->getName();
484
        $tmpFile = $request->postVar($name);
485
        if (!$tmpFile) {
486
            throw new RuntimeException("No file");
487
        }
488
        $tmpFile = $this->normalizeTempFile($tmpFile);
489
490
        // Update $tmpFile with a better name
491
        if ($this->renamePattern) {
492
            $tmpFile['name'] = $this->changeFilenameWithPattern(
493
                $tmpFile['name'],
494
                $this->renamePattern
495
            );
496
        }
497
498
        return $tmpFile;
499
    }
500
501
    /**
502
     * @param HTTPRequest $request
503
     * @return void
504
     */
505
    protected function securityChecks(HTTPRequest $request)
506
    {
507
        if ($this->isDisabled() || $this->isReadonly()) {
508
            throw new RuntimeException("Field is disabled or readonly");
509
        }
510
511
        // CSRF check
512
        $token = $this->getForm()->getSecurityToken();
513
        if (!$token->checkRequest($request)) {
514
            throw new RuntimeException("Invalid token");
515
        }
516
    }
517
518
    /**
519
     * @param File $file
520
     * @param HTTPRequest $request
521
     * @return void
522
     */
523
    protected function setFileDetails(File $file, HTTPRequest $request)
524
    {
525
        // Mark as temporary until properly associated with a record
526
        // Files will be unmarked later on by saveInto method
527
        $file->IsTemporary = true;
528
529
        // We can also track the record
530
        $RecordID = $request->getHeader('X-RecordID');
531
        $RecordClassName = $request->getHeader('X-RecordClassName');
532
        if (!$file->ObjectID) {
533
            $file->ObjectID = $RecordID;
534
        }
535
        if (!$file->ObjectClass) {
536
            $file->ObjectClass = $RecordClassName;
537
        }
538
539
        if ($file->isChanged()) {
540
            // If possible, prevent creating a version for no reason
541
            // @link https://docs.silverstripe.org/en/4/developer_guides/model/versioning/#writing-changes-to-a-versioned-dataobject
542
            if ($file->hasExtension(Versioned::class)) {
543
                $file->writeWithoutVersion();
544
            } else {
545
                $file->write();
546
            }
547
        }
548
    }
549
550
    /**
551
     * Creates a single file based on a form-urlencoded upload.
552
     *
553
     * 1 client uploads file my-file.jpg as multipart/form-data using a POST request
554
     * 2 server saves file to unique location tmp/12345/my-file.jpg
555
     * 3 server returns unique location id 12345 in text/plain response
556
     * 4 client stores unique id 12345 in a hidden input field
557
     * 5 client submits the FilePond parent form containing the hidden input field with the unique id
558
     * 6 server uses the unique id to move tmp/12345/my-file.jpg to its final location and remove the tmp/12345 folder
559
     *
560
     * Along with the file object, FilePond also sends the file metadata to the server, both these objects are given the same name.
561
     *
562
     * @param HTTPRequest $request
563
     * @return HTTPResponse
564
     */
565
    public function upload(HTTPRequest $request)
566
    {
567
        try {
568
            $this->securityChecks($request);
569
            $tmpFile = $this->prepareUpload($request);
570
        } catch (Exception $ex) {
571
            return $this->httpError(400, $ex->getMessage());
572
        }
573
574
        $file = $this->saveTemporaryFile($tmpFile, $error);
575
576
        // Handle upload errors
577
        if ($error) {
578
            $this->getUpload()->clearErrors();
579
            return $this->httpError(400, json_encode($error));
580
        }
581
582
        // File can be an AssetContainer and not a DataObject
583
        if ($file instanceof DataObject) {
584
            $this->setFileDetails($file, $request);
585
        }
586
587
        $this->getUpload()->clearErrors();
588
        $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...
589
        $this->trackFileID($fileId);
590
591
        if (self::config()->auto_clear_temp_folder) {
592
            // Set a limit of 100 because otherwise it would be really slow
593
            self::clearTemporaryUploads(true, 100);
594
        }
595
596
        // server returns unique location id 12345 in text/plain response
597
        $response = new HTTPResponse($fileId);
598
        $response->addHeader('Content-Type', 'text/plain');
599
        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...
600
    }
601
602
    /**
603
     * @link https://pqina.nl/filepond/docs/api/server/#process-chunks
604
     * @param HTTPRequest $request
605
     * @return void
606
     */
607
    public function chunk(HTTPRequest $request)
608
    {
609
        try {
610
            $this->securityChecks($request);
611
        } catch (Exception $ex) {
612
            return $this->httpError(400, $ex->getMessage());
613
        }
614
615
        $method = $request->httpMethod();
616
617
        // The random token is returned as a query string
618
        $id = $request->getVar('patch');
619
620
        // FilePond will send a POST request (without file) to start a chunked transfer,
621
        // expecting to receive a unique transfer id in the response body, it'll add the Upload-Length header to this request.
622
        if ($method == "POST") {
623
            // Initial post payload doesn't contain name
624
            $file = new File();
625
            $this->setFileDetails($file, $request);
626
            $fileId = $file->ID;
627
            $this->trackFileID($fileId);
628
            $response = new HTTPResponse($fileId, 200);
629
            $response->addHeader('Content-Type', 'text/plain');
630
            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...
631
        }
632
633
        // FilePond will send a HEAD request to determine which chunks have already been uploaded,
634
        // expecting the file offset of the next expected chunk in the Upload-Offset response header.
635
        if ($method == "HEAD") {
636
            $nextOffset = 0;
637
638
            //TODO: iterate over temp files and check next offset
639
            $response = new HTTPResponse($nextOffset, 200);
640
            $response->addHeader('Content-Type', 'text/plain');
641
            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...
642
        }
643
644
        // FilePond will send a PATCH request to push a chunk to the server.
645
        // Each of these requests is accompanied by a Content-Type, Upload-Offset, Upload-Name, and Upload-Length header.
646
        if ($method != "PATCH") {
647
            return $this->httpError(400, "Invalid method");
648
        }
649
650
        // The name of the file being transferred
651
        $uploadName = $request->getHeader('Upload-Name');
652
        // The total size of the file being transferred
653
        $offset = $request->getHeader('Upload-Offset');
654
        // The offset of the chunk being transferred
655
        $length = $request->getHeader('Upload-Length');
656
657
        // location of patch files
658
        $filePath = TEMP_PATH . "/filepond-" . $id;
659
660
        // should be numeric values, else exit
661
        if (!is_numeric($offset) || !is_numeric($length)) {
662
            return $this->httpError(400, "Invalid offset or length");
663
        }
664
665
        // write patch file for this request
666
        file_put_contents($filePath . '.patch.' . $offset, $request->getBody());
667
668
        // calculate total size of patches
669
        $size = 0;
670
        $patch = glob($filePath . '.patch.*');
671
        foreach ($patch as $filename) {
672
            $size += filesize($filename);
673
        }
674
        // if total size equals length of file we have gathered all patch files
675
        if ($size >= $length) {
676
            // create output file
677
            $outputFile = fopen($filePath, 'wb');
678
            // write patches to file
679
            foreach ($patch as $filename) {
680
                // get offset from filename
681
                list($dir, $offset) = explode('.patch.', $filename, 2);
682
                // read patch and close
683
                $patchFile = fopen($filename, 'rb');
684
                $patchContent = fread($patchFile, filesize($filename));
685
                fclose($patchFile);
686
687
                // apply patch
688
                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

688
                fseek($outputFile, /** @scrutinizer ignore-type */ $offset);
Loading history...
689
                fwrite($outputFile, $patchContent);
690
            }
691
            // remove patches
692
            foreach ($patch as $filename) {
693
                unlink($filename);
694
            }
695
            // done with file
696
            fclose($outputFile);
697
698
            // Finalize real filename
699
            $realFilename = $this->getFolderName() . "/" . $uploadName;
700
            if ($this->renamePattern) {
701
                $realFilename = $this->changeFilenameWithPattern(
702
                    $realFilename,
703
                    $this->renamePattern
704
                );
705
            }
706
707
            // write output file to asset store
708
            $file = $this->getFileByID($id);
709
            if (!$file) {
0 ignored issues
show
introduced by
$file is of type SilverStripe\Assets\File, thus it always evaluated to true.
Loading history...
710
                return $this->httpError("File $id not found");
711
            }
712
            $file->setFromLocalFile($filePath);
713
            $file->setFilename($realFilename);
714
            $file->write();
715
        }
716
        $response = new HTTPResponse('', 204);
717
        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...
718
    }
719
720
    /**
721
     * Clear temp folder that should not contain any file other than temporary
722
     *
723
     * @param boolean $doDelete Set to true to actually delete the files, otherwise it's just a dry-run
724
     * @param int $limit
725
     * @return File[] List of files removed
726
     */
727
    public static function clearTemporaryUploads($doDelete = false, $limit = 0)
728
    {
729
        $tempFiles = File::get()->filter('IsTemporary', true);
730
        if ($limit) {
731
            $tempFiles = $tempFiles->limit($limit);
732
        }
733
734
        $threshold = self::config()->auto_clear_threshold;
735
736
        // Set a default threshold if none set
737
        if (!$threshold) {
738
            if (Director::isDev()) {
739
                $threshold = '-10 minutes';
740
            } else {
741
                $threshold = '-1 day';
742
            }
743
        }
744
        if (is_int($threshold)) {
745
            $thresholdTime = time() - $threshold;
746
        } else {
747
            $thresholdTime = strtotime($threshold);
748
        }
749
750
        // Update query to avoid fetching unecessary records
751
        $tempFiles = $tempFiles->filter("Created:LessThan", date('Y-m-d H:i:s', $thresholdTime));
752
753
        $filesDeleted = [];
754
        foreach ($tempFiles as $tempFile) {
755
            $createdTime = strtotime($tempFile->Created);
756
            if ($createdTime < $thresholdTime) {
757
                $filesDeleted[] = $tempFile;
758
                if ($doDelete) {
759
                    if ($tempFile->hasExtension(Versioned::class)) {
760
                        $tempFile->deleteFromStage(Versioned::LIVE);
761
                        $tempFile->deleteFromStage(Versioned::DRAFT);
762
                    } else {
763
                        $tempFile->delete();
764
                    }
765
                }
766
            }
767
        }
768
        return $filesDeleted;
769
    }
770
771
    /**
772
     * Allows tracking uploaded ids to prevent unauthorized attachements
773
     *
774
     * @param int $fileId
775
     * @return void
776
     */
777
    public function trackFileID($fileId)
778
    {
779
        $session = $this->getRequest()->getSession();
780
        $uploadedIDs = $this->getTrackedIDs();
781
        if (!in_array($fileId, $uploadedIDs)) {
782
            $uploadedIDs[] = $fileId;
783
        }
784
        $session->set('FilePond', $uploadedIDs);
785
    }
786
787
    /**
788
     * Get all authorized tracked ids
789
     * @return array
790
     */
791
    public function getTrackedIDs()
792
    {
793
        $session = $this->getRequest()->getSession();
794
        $uploadedIDs = $session->get('FilePond');
795
        if ($uploadedIDs) {
796
            return $uploadedIDs;
797
        }
798
        return [];
799
    }
800
801
    public function saveInto(DataObjectInterface $record)
802
    {
803
        // Note that the list of IDs is based on the value sent by the user
804
        // It can be spoofed because checks are minimal (by default, canView = true and only check if isInDB)
805
        $IDs = $this->getItemIDs();
806
807
        $Member = Security::getCurrentUser();
808
809
        // Ensure the files saved into the DataObject have been tracked (either because already on the DataObject or uploaded by the user)
810
        $trackedIDs = $this->getTrackedIDs();
811
        foreach ($IDs as $ID) {
812
            if (!in_array($ID, $trackedIDs)) {
813
                throw new ValidationException("Invalid file ID : $ID");
814
            }
815
        }
816
817
        // Move files out of temporary folder
818
        foreach ($IDs as $ID) {
819
            $file = $this->getFileByID($ID);
820
            if ($file && $file->IsTemporary) {
821
                // The record does not have an ID which is a bad idea to attach the file to it
822
                if (!$record->ID) {
823
                    $record->write();
824
                }
825
                // Check if the member is owner
826
                if ($Member && $Member->ID != $file->OwnerID) {
827
                    throw new ValidationException("Failed to authenticate owner");
828
                }
829
                $file->IsTemporary = false;
830
                $file->ObjectID = $record->ID;
831
                $file->ObjectClass = get_class($record);
832
                $file->write();
833
            } else {
834
                // File was uploaded earlier, no need to do anything
835
            }
836
        }
837
838
        // Proceed
839
        return parent::saveInto($record);
840
    }
841
842
    public function Type()
843
    {
844
        return 'filepond';
845
    }
846
}
847