Passed
Push — master ( 11964d...939c3e )
by Thomas
02:22
created

FilePondField::computeMaxChunkSize()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 1
eloc 3
c 1
b 1
f 0
nc 1
nop 0
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\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 int
110
     */
111
    private static $poster_width = 352;
112
113
    /**
114
     * @config
115
     * @var int
116
     */
117
    private static $poster_height = 264;
118
119
    /**
120
     * @var array
121
     */
122
    protected $filePondConfig = [];
123
124
    /**
125
     * Create a new file field.
126
     *
127
     * @param string $name The internal field name, passed to forms.
128
     * @param string $title The field label.
129
     * @param SS_List $items Items assigned to this field
130
     */
131
    public function __construct($name, $title = null, SS_List $items = null)
132
    {
133
        parent::__construct($name, $title, $items);
134
135
        if (self::config()->chunk_by_default) {
136
            $this->setChunkUploads(true);
137
        }
138
    }
139
140
    /**
141
     * Set a custom config value for this field
142
     *
143
     * @link https://pqina.nl/filepond/docs/patterns/api/filepond-instance/#properties
144
     * @param string $k
145
     * @param string $v
146
     * @return $this
147
     */
148
    public function addFilePondConfig($k, $v)
149
    {
150
        $this->filePondConfig[$k] = $v;
151
        return $this;
152
    }
153
154
    /**
155
     * Custom configuration applied to this field
156
     *
157
     * @return array
158
     */
159
    public function getCustomFilePondConfig()
160
    {
161
        return $this->filePondConfig;
162
    }
163
    /**
164
     * Get the value of chunkUploads
165
     * @return bool
166
     */
167
    public function getChunkUploads()
168
    {
169
        if (!isset($this->filePondConfig['chunkUploads'])) {
170
            return false;
171
        }
172
        return $this->filePondConfig['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->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

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

700
                fseek($outputFile, /** @scrutinizer ignore-type */ $offset);
Loading history...
701
                fwrite($outputFile, $patchContent);
702
            }
703
            // remove patches
704
            foreach ($patch as $filename) {
705
                unlink($filename);
706
            }
707
            // done with file
708
            fclose($outputFile);
709
710
            // Finalize real filename
711
            $realFilename = $this->getFolderName() . "/" . $uploadName;
712
            if ($this->renamePattern) {
713
                $realFilename = $this->changeFilenameWithPattern(
714
                    $realFilename,
715
                    $this->renamePattern
716
                );
717
            }
718
719
            // write output file to asset store
720
            $file = $this->getFileByID($id);
721
            if (!$file) {
0 ignored issues
show
introduced by
$file is of type SilverStripe\Assets\File, thus it always evaluated to true.
Loading history...
722
                return $this->httpError("File $id not found");
723
            }
724
            $file->setFromLocalFile($filePath);
725
            $file->setFilename($realFilename);
726
            $file->Title = $uploadName;
727
            // Is an image ?
728
            $imageExtensions = File::get_category_extensions('image/supported');
729
            if (in_array(pathinfo($realFilename, PATHINFO_EXTENSION), $imageExtensions)) {
730
                $file->setClassName(Image::class);
731
            }
732
            $file->write();
733
        }
734
        $response = new HTTPResponse('', 204);
735
        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...
736
    }
737
738
    /**
739
     * @link https://pqina.nl/filepond/docs/api/server/#revert
740
     * @param HTTPRequest $request
741
     * @return void
742
     */
743
    public function revert(HTTPRequest $request)
744
    {
745
        try {
746
            $this->securityChecks($request);
747
        } catch (Exception $ex) {
748
            return $this->httpError(400, $ex->getMessage());
749
        }
750
751
        $method = $request->httpMethod();
752
753
        if ($method != "DELETE") {
754
            return $this->httpError(400, "Invalid method");
755
        }
756
757
        $fileID = (int) $request->getBody();
758
        if (!in_array($fileID, $this->getTrackedIDs())) {
759
            return $this->httpError(400, "Invalid ID");
760
        }
761
        $file = File::get()->byID($fileID);
762
        if (!$file->IsTemporary) {
763
            return $this->httpError(400, "Invalid file");
764
        }
765
        if (!$file->canDelete()) {
766
            return $this->httpError(400, "Cannot delete file");
767
        }
768
        $file->delete();
769
        $response = new HTTPResponse('', 200);
770
        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...
771
    }
772
773
    /**
774
     * Clear temp folder that should not contain any file other than temporary
775
     *
776
     * @param boolean $doDelete Set to true to actually delete the files, otherwise it's just a dry-run
777
     * @param int $limit
778
     * @return File[] List of files removed
779
     */
780
    public static function clearTemporaryUploads($doDelete = false, $limit = 0)
781
    {
782
        $tempFiles = File::get()->filter('IsTemporary', true);
783
        if ($limit) {
784
            $tempFiles = $tempFiles->limit($limit);
785
        }
786
787
        $threshold = self::config()->auto_clear_threshold;
788
789
        // Set a default threshold if none set
790
        if (!$threshold) {
791
            if (Director::isDev()) {
792
                $threshold = '-10 minutes';
793
            } else {
794
                $threshold = '-1 day';
795
            }
796
        }
797
        if (is_int($threshold)) {
798
            $thresholdTime = time() - $threshold;
799
        } else {
800
            $thresholdTime = strtotime($threshold);
801
        }
802
803
        // Update query to avoid fetching unecessary records
804
        $tempFiles = $tempFiles->filter("Created:LessThan", date('Y-m-d H:i:s', $thresholdTime));
805
806
        $filesDeleted = [];
807
        foreach ($tempFiles as $tempFile) {
808
            $createdTime = strtotime($tempFile->Created);
809
            if ($createdTime < $thresholdTime) {
810
                $filesDeleted[] = $tempFile;
811
                if ($doDelete) {
812
                    if ($tempFile->hasExtension(Versioned::class)) {
813
                        $tempFile->deleteFromStage(Versioned::LIVE);
814
                        $tempFile->deleteFromStage(Versioned::DRAFT);
815
                    } else {
816
                        $tempFile->delete();
817
                    }
818
                }
819
            }
820
        }
821
        return $filesDeleted;
822
    }
823
824
    /**
825
     * Allows tracking uploaded ids to prevent unauthorized attachements
826
     *
827
     * @param int $fileId
828
     * @return void
829
     */
830
    public function trackFileID($fileId)
831
    {
832
        $session = $this->getRequest()->getSession();
833
        $uploadedIDs = $this->getTrackedIDs();
834
        if (!in_array($fileId, $uploadedIDs)) {
835
            $uploadedIDs[] = $fileId;
836
        }
837
        $session->set('FilePond', $uploadedIDs);
838
    }
839
840
    /**
841
     * Get all authorized tracked ids
842
     * @return array
843
     */
844
    public function getTrackedIDs()
845
    {
846
        $session = $this->getRequest()->getSession();
847
        $uploadedIDs = $session->get('FilePond');
848
        if ($uploadedIDs) {
849
            return $uploadedIDs;
850
        }
851
        return [];
852
    }
853
854
    public function saveInto(DataObjectInterface $record)
855
    {
856
        // Note that the list of IDs is based on the value sent by the user
857
        // It can be spoofed because checks are minimal (by default, canView = true and only check if isInDB)
858
        $IDs = $this->getItemIDs();
859
860
        $Member = Security::getCurrentUser();
861
862
        // Ensure the files saved into the DataObject have been tracked (either because already on the DataObject or uploaded by the user)
863
        $trackedIDs = $this->getTrackedIDs();
864
        foreach ($IDs as $ID) {
865
            if (!in_array($ID, $trackedIDs)) {
866
                throw new ValidationException("Invalid file ID : $ID");
867
            }
868
        }
869
870
        // Move files out of temporary folder
871
        foreach ($IDs as $ID) {
872
            $file = $this->getFileByID($ID);
873
            if ($file && $file->IsTemporary) {
874
                // The record does not have an ID which is a bad idea to attach the file to it
875
                if (!$record->ID) {
876
                    $record->write();
877
                }
878
                // Check if the member is owner
879
                if ($Member && $Member->ID != $file->OwnerID) {
880
                    throw new ValidationException("Failed to authenticate owner");
881
                }
882
                $file->IsTemporary = false;
883
                $file->ObjectID = $record->ID;
884
                $file->ObjectClass = get_class($record);
885
                $file->write();
886
            } else {
887
                // File was uploaded earlier, no need to do anything
888
            }
889
        }
890
891
        // Proceed
892
        return parent::saveInto($record);
893
    }
894
895
    public function Type()
896
    {
897
        return 'filepond';
898
    }
899
}
900