Passed
Push — master ( f3c5e1...a52d27 )
by Thomas
02:58
created

FilePondField::securityChecks()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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

632
                fseek($outputFile, /** @scrutinizer ignore-type */ $offset);
Loading history...
633
                fwrite($outputFile, $patchContent);
634
            }
635
            // remove patches
636
            foreach ($patch as $filename) {
637
                unlink($filename);
638
            }
639
            // done with file
640
            fclose($outputFile);
641
642
            // Finalize real filename
643
            $realFilename = $this->getFolderName() . "/" . $uploadName;
644
            if ($this->renamePattern) {
645
                $realFilename = $this->changeFilenameWithPattern(
646
                    $realFilename,
647
                    $this->renamePattern
648
                );
649
            }
650
651
            // write output file to asset store
652
            $file = $this->getFileByID($id);
653
            if (!$file) {
0 ignored issues
show
introduced by
$file is of type SilverStripe\Assets\File, thus it always evaluated to true.
Loading history...
654
                return $this->httpError("File $id not found");
655
            }
656
            $file->setFromLocalFile($filePath);
657
            $file->setFilename($realFilename);
658
            $file->write();
659
        }
660
        $response = new HTTPResponse('', 204);
661
        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...
662
    }
663
664
    /**
665
     * Clear temp folder that should not contain any file other than temporary
666
     *
667
     * @param boolean $doDelete Set to true to actually delete the files, otherwise it's just a dry-run
668
     * @param int $limit
669
     * @return File[] List of files removed
670
     */
671
    public static function clearTemporaryUploads($doDelete = false, $limit = 0)
672
    {
673
        $tempFiles = File::get()->filter('IsTemporary', true);
674
        if ($limit) {
675
            $tempFiles = $tempFiles->limit($limit);
676
        }
677
678
        $threshold = self::config()->auto_clear_threshold;
679
680
        // Set a default threshold if none set
681
        if (!$threshold) {
682
            if (Director::isDev()) {
683
                $threshold = '-10 minutes';
684
            } else {
685
                $threshold = '-1 day';
686
            }
687
        }
688
        if (is_int($threshold)) {
689
            $thresholdTime = time() - $threshold;
690
        } else {
691
            $thresholdTime = strtotime($threshold);
692
        }
693
694
        // Update query to avoid fetching unecessary records
695
        $tempFiles = $tempFiles->filter("Created:LessThan", date('Y-m-d H:i:s', $thresholdTime));
696
697
        $filesDeleted = [];
698
        foreach ($tempFiles as $tempFile) {
699
            $createdTime = strtotime($tempFile->Created);
700
            if ($createdTime < $thresholdTime) {
701
                $filesDeleted[] = $tempFile;
702
                if ($doDelete) {
703
                    if ($tempFile->hasExtension(Versioned::class)) {
704
                        $tempFile->deleteFromStage(Versioned::LIVE);
705
                        $tempFile->deleteFromStage(Versioned::DRAFT);
706
                    } else {
707
                        $tempFile->delete();
708
                    }
709
                }
710
            }
711
        }
712
        return $filesDeleted;
713
    }
714
715
    /**
716
     * Allows tracking uploaded ids to prevent unauthorized attachements
717
     *
718
     * @param int $fileId
719
     * @return void
720
     */
721
    public function trackFileID($fileId)
722
    {
723
        $session = $this->getRequest()->getSession();
724
        $uploadedIDs = $this->getTrackedIDs();
725
        if (!in_array($fileId, $uploadedIDs)) {
726
            $uploadedIDs[] = $fileId;
727
        }
728
        $session->set('FilePond', $uploadedIDs);
729
    }
730
731
    /**
732
     * Get all authorized tracked ids
733
     * @return array
734
     */
735
    public function getTrackedIDs()
736
    {
737
        $session = $this->getRequest()->getSession();
738
        $uploadedIDs = $session->get('FilePond');
739
        if ($uploadedIDs) {
740
            return $uploadedIDs;
741
        }
742
        return [];
743
    }
744
745
    public function saveInto(DataObjectInterface $record)
746
    {
747
        // Note that the list of IDs is based on the value sent by the user
748
        // It can be spoofed because checks are minimal (by default, canView = true and only check if isInDB)
749
        $IDs = $this->getItemIDs();
750
751
        $Member = Security::getCurrentUser();
752
753
        // Ensure the files saved into the DataObject have been tracked (either because already on the DataObject or uploaded by the user)
754
        $trackedIDs = $this->getTrackedIDs();
755
        foreach ($IDs as $ID) {
756
            if (!in_array($ID, $trackedIDs)) {
757
                throw new ValidationException("Invalid file ID : $ID");
758
            }
759
        }
760
761
        // Move files out of temporary folder
762
        foreach ($IDs as $ID) {
763
            $file = $this->getFileByID($ID);
764
            if ($file && $file->IsTemporary) {
765
                // The record does not have an ID which is a bad idea to attach the file to it
766
                if (!$record->ID) {
767
                    $record->write();
768
                }
769
                // Check if the member is owner
770
                if ($Member && $Member->ID != $file->OwnerID) {
771
                    throw new ValidationException("Failed to authenticate owner");
772
                }
773
                $file->IsTemporary = false;
774
                $file->ObjectID = $record->ID;
775
                $file->ObjectClass = get_class($record);
776
                $file->write();
777
            } else {
778
                // File was uploaded earlier, no need to do anything
779
            }
780
        }
781
782
        // Proceed
783
        return parent::saveInto($record);
784
    }
785
786
    public function Type()
787
    {
788
        return 'filepond';
789
    }
790
}
791