Passed
Push — master ( 615624...11964d )
by Thomas
02:24
created

FilePondField::Type()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
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\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
        'revert',
38
    ];
39
40
    /**
41
     * @config
42
     * @var boolean
43
     */
44
    private static $enable_requirements = true;
45
46
    /**
47
     * @config
48
     * @var boolean
49
     */
50
    private static $enable_validation = true;
51
52
    /**
53
     * @config
54
     * @var boolean
55
     */
56
    private static $enable_poster = false;
57
58
    /**
59
     * @config
60
     * @var boolean
61
     */
62
    private static $enable_image = false;
63
64
    /**
65
     * @config
66
     * @var boolean
67
     */
68
    private static $enable_polyfill = true;
69
70
    /**
71
     * @config
72
     * @var boolean
73
     */
74
    private static $enable_ajax_init = true;
75
76
    /**
77
     * @config
78
     * @var boolean
79
     */
80
    private static $chunk_by_default = false;
81
82
    /**
83
     * @config
84
     * @var boolean
85
     */
86
    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...
87
88
    /**
89
     * @config
90
     * @var boolean
91
     */
92
    private static $auto_clear_temp_folder = true;
93
94
    /**
95
     * @config
96
     * @var int
97
     */
98
    private static $auto_clear_threshold = true;
99
100
    /**
101
     * @config
102
     * @var boolean
103
     */
104
    private static $use_cdn = true;
105
106
    /**
107
     * @config
108
     * @var int
109
     */
110
    private static $poster_width = 352;
111
112
    /**
113
     * @config
114
     * @var int
115
     */
116
    private static $poster_height = 264;
117
118
    /**
119
     * @var array
120
     */
121
    protected $filePondConfig = [];
122
123
    /**
124
     * Create a new file field.
125
     *
126
     * @param string $name The internal field name, passed to forms.
127
     * @param string $title The field label.
128
     * @param SS_List $items Items assigned to this field
129
     */
130
    public function __construct($name, $title = null, SS_List $items = null)
131
    {
132
        parent::__construct($name, $title, $items);
133
134
        if (self::config()->chunk_by_default) {
135
            $this->setChunkUploads(true);
136
        }
137
    }
138
139
    /**
140
     * Set a custom config value for this field
141
     *
142
     * @link https://pqina.nl/filepond/docs/patterns/api/filepond-instance/#properties
143
     * @param string $k
144
     * @param string $v
145
     * @return $this
146
     */
147
    public function addFilePondConfig($k, $v)
148
    {
149
        $this->filePondConfig[$k] = $v;
150
        return $this;
151
    }
152
153
    /**
154
     * Custom configuration applied to this field
155
     *
156
     * @return array
157
     */
158
    public function getCustomFilePondConfig()
159
    {
160
        return $this->filePondConfig;
161
    }
162
    /**
163
     * Get the value of chunkUploads
164
     * @return bool
165
     */
166
    public function getChunkUploads()
167
    {
168
        if (!isset($this->filePondConfig['chunkUploads'])) {
169
            return false;
170
        }
171
        return $this->filePondConfig['chunkUploads'];
172
    }
173
174
    /**
175
     * Set the value of chunkUploads
176
     *
177
     * @param bool $chunkUploads
178
     * @return $this
179
     */
180
    public function setChunkUploads($chunkUploads)
181
    {
182
        $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

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

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