Passed
Push — master ( d90b4e...f33e4b )
by Thomas
12:35
created

ExcelGridFieldExportButton::setSanitizeXls()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 4
rs 10
1
<?php
2
3
namespace LeKoala\ExcelImportExport;
4
5
use Generator;
6
use InvalidArgumentException;
7
use SilverStripe\ORM\DataList;
8
use SilverStripe\ORM\ArrayList;
9
use SilverStripe\ORM\SS_List;
10
use SilverStripe\Control\HTTPRequest;
11
use LeKoala\SpreadCompat\SpreadCompat;
0 ignored issues
show
Bug introduced by
The type LeKoala\SpreadCompat\SpreadCompat was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
12
use SilverStripe\Assets\FileNameFilter;
13
use SilverStripe\Forms\GridField\GridField;
14
use SilverStripe\Forms\GridField\GridFieldPaginator;
15
use SilverStripe\Forms\GridField\GridField_FormAction;
16
use SilverStripe\Forms\GridField\GridField_URLHandler;
17
use SilverStripe\Forms\GridField\GridFieldFilterHeader;
18
use SilverStripe\Forms\GridField\GridField_HTMLProvider;
19
use SilverStripe\Forms\GridField\GridFieldSortableHeader;
20
use SilverStripe\Forms\GridField\GridField_ActionProvider;
21
22
/**
23
 * Adds an "Export list" button to the bottom of a {@link GridField}.
24
 */
25
class ExcelGridFieldExportButton implements
26
    GridField_HTMLProvider,
27
    GridField_ActionProvider,
28
    GridField_URLHandler
29
{
30
    /**
31
     * Map of a property name on the exported objects, with values being the column title in the file.
32
     * Note that titles are only used when {@link $hasHeader} is set to TRUE.
33
     * @var array<int|string,mixed>
34
     */
35
    protected ?array $exportColumns;
36
37
    /**
38
     * Fragment to write the button to
39
     */
40
    protected string $targetFragment;
41
42
    protected bool $hasHeader = true;
43
44
    protected string $exportType = 'xlsx';
45
46
    protected ?string $exportName = null;
47
48
    protected ?string $buttonTitle = null;
49
50
    protected bool $checkCanView = true;
51
52
    protected bool $isLimited = true;
53
54
    /**
55
     * @var array<mixed>
56
     */
57
    protected array $listFilters = [];
58
59
    /**
60
     *
61
     * @var null|callable
62
     */
63
    protected $afterExportCallback;
64
65
    protected bool $ignoreFilters = false;
66
67
    protected bool $sanitizeXls = true;
68
69
    /**
70
     * @param string $targetFragment The HTML fragment to write the button into
71
     * @param array<string> $exportColumns The columns to include in the export
72
     */
73
    public function __construct($targetFragment = "after", $exportColumns = null)
74
    {
75
        $this->targetFragment = $targetFragment;
76
        $this->exportColumns = $exportColumns;
77
    }
78
79
    /**
80
     * @param GridField $gridField
81
     * @return string
82
     */
83
    public function getActionName($gridField)
84
    {
85
        $name = strtolower($gridField->getName());
86
        return 'excelexport_' . $name;
87
    }
88
89
    /**
90
     * Place the export button in a <p> tag below the field
91
     * @return array<string|int,mixed>
92
     */
93
    public function getHTMLFragments($gridField)
94
    {
95
        $defaultTitle = _t(
96
            'ExcelImportExport.FORMATEXPORT',
97
            'Export to {format}',
98
            ['format' => $this->exportType]
99
        );
100
        $title = $this->buttonTitle ? $this->buttonTitle : $defaultTitle;
101
102
        $name = $this->getActionName($gridField);
103
104
        $button = new GridField_FormAction(
105
            $gridField,
106
            $name,
107
            $title,
108
            $name,
109
            []
110
        );
111
        $button->addExtraClass('btn btn-secondary no-ajax font-icon-down-circled action_export');
112
        $button->setForm($gridField->getForm());
113
114
        return array(
115
            $this->targetFragment => $button->Field()
116
        );
117
    }
118
119
    /**
120
     * export is an action button
121
     * @param GridField $gridField
122
     * @return array<string>
123
     */
124
    public function getActions($gridField)
125
    {
126
        return array($this->getActionName($gridField));
127
    }
128
129
    /**
130
     * @param GridField $gridField
131
     * @param string $actionName
132
     * @param array<mixed> $arguments
133
     * @param array<mixed> $data
134
     * @return void
135
     */
136
    public function handleAction(
137
        GridField $gridField,
138
        $actionName,
139
        $arguments,
140
        $data
141
    ) {
142
        if (in_array($actionName, $this->getActions($gridField))) {
143
            $this->handleExport($gridField);
144
        }
145
    }
146
147
    /**
148
     * it is also a URL
149
     * @param GridField $gridField
150
     * @return array<string,string>
151
     */
152
    public function getURLHandlers($gridField)
153
    {
154
        return array($this->getActionName($gridField) => 'handleExport');
155
    }
156
157
    /**
158
     * Handle the export, for both the action button and the URL
159
     * @param GridField $gridField
160
     * @param HTTPRequest $request
161
     * @return void
162
     */
163
    public function handleExport($gridField, $request = null)
164
    {
165
        $now = date("Ymd_Hi");
166
167
        $this->updateExportName($gridField);
168
169
        $data = $this->generateExportFileData($gridField);
170
171
        $ext = $this->exportType;
172
        $name = $this->exportName;
173
        $fileName = "$name-$now.$ext";
174
175
        if ($this->afterExportCallback) {
176
            $func = $this->afterExportCallback;
177
            $func();
178
        }
179
180
        $opts = [
181
            'extension' => $ext,
182
        ];
183
184
        if ($ext != 'csv') {
185
            $end = ExcelImportExport::getLetter(count($this->getRealExportColumns($gridField)));
186
            $opts['creator'] = "SilverStripe";
187
            $opts['autofilter'] = "A1:{$end}1";
188
        }
189
190
        SpreadCompat::$preferredCsvAdapter = SpreadCompat::NATIVE;
191
        SpreadCompat::output($data, $fileName, ...$opts);
192
        exit();
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
193
    }
194
195
196
    /**
197
     * Make sure export name is a valid file name
198
     * @param GridField|\LeKoala\Tabulator\TabulatorGrid $gridField
0 ignored issues
show
Bug introduced by
The type LeKoala\Tabulator\TabulatorGrid was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
199
     * @return void
200
     */
201
    protected function updateExportName($gridField)
202
    {
203
        $filter = new FileNameFilter;
204
        if ($this->exportName) {
205
            $this->exportName = $filter->filter($this->exportName);
206
        } else {
207
            $class = $gridField->getModelClass();
208
            $singl = singleton($class);
209
            $plural = $class ? $singl->i18n_plural_name() : '';
210
211
            $this->exportName = $filter->filter('export-' . $plural);
212
        }
213
    }
214
215
    /**
216
     * @param GridField|\LeKoala\Tabulator\TabulatorGrid $gridField
217
     * @return DataList|ArrayList|SS_List|null
218
     */
219
    protected function retrieveList($gridField)
220
    {
221
        // Remove GridFieldPaginator as we're going to export the entire list.
222
        $gridField->getConfig()->removeComponentsByType(GridFieldPaginator::class);
223
224
        /** @var DataList|ArrayList $items */
225
        $items = $gridField->getManipulatedList();
226
227
        // Keep filters
228
        if (!$this->ignoreFilters) {
229
            foreach ($gridField->getConfig()->getComponents() as $component) {
230
                if ($component instanceof GridFieldFilterHeader || $component instanceof GridFieldSortableHeader) {
231
                    //@phpstan-ignore-next-line
232
                    $items = $component->getManipulatedData($gridField, $items);
233
                }
234
            }
235
        }
236
237
        $list = $items;
238
        $limit = ExcelImportExport::$limit_exports;
239
        if ($list instanceof DataList) {
240
            if ($this->isLimited && $limit > 0) {
241
                $list = $list->limit($limit);
242
            }
243
            if (!empty($this->listFilters)) {
244
                $list = $list->filter($this->listFilters);
245
            }
246
        }
247
        return $list;
248
    }
249
250
    /**
251
     * @param GridField|\LeKoala\Tabulator\TabulatorGrid $gridField
252
     * @return array<int|string,mixed|null>
253
     */
254
    protected function getRealExportColumns($gridField)
255
    {
256
        $class = $gridField->getModelClass();
257
        return ($this->exportColumns) ? $this->exportColumns : ExcelImportExport::exportFieldsForClass($class);
258
    }
259
260
    /**
261
     * Generate export fields for Excel.
262
     *
263
     * @param GridField|\LeKoala\Tabulator\TabulatorGrid $gridField
264
     */
265
    public function generateExportFileData($gridField): Generator
266
    {
267
        $columns = $this->getRealExportColumns($gridField);
268
269
        if ($this->hasHeader) {
270
            $headers = [];
271
272
            // determine the headers. If a field is callable (e.g. anonymous function) then use the
273
            // source name as the header instead
274
            foreach ($columns as $columnSource => $columnHeader) {
275
                //@phpstan-ignore-next-line
276
                if (is_array($columnHeader) && array_key_exists('title', $columnHeader ?? [])) {
277
                    $headers[] = $columnHeader['title'];
278
                } else {
279
                    $headers[] = (!is_string($columnHeader) && is_callable($columnHeader)) ? $columnSource : $columnHeader;
280
                }
281
            }
282
283
            yield $headers;
284
        }
285
286
        $list = $this->retrieveList($gridField);
287
288
        if (!$list) {
289
            return;
290
        }
291
292
        // Auto format using DBField methods based on column name
293
        $export_format = ExcelImportExport::config()->export_format;
294
295
        $sanitize = $this->isSanitizeEnabled();
296
297
        foreach ($list as $item) {
298
            // This can be really slow for large exports depending on how canView is implemented
299
            if ($this->checkCanView) {
300
                $canView = true;
301
                if ($item->hasMethod('canView') && !$item->canView()) {
302
                    $canView = false;
303
                }
304
                if (!$canView) {
305
                    continue;
306
                }
307
            }
308
309
            $dataRow = [];
310
311
            // Loop and transforms records as needed
312
            foreach ($columns as $columnSource => $columnHeader) {
313
                if (!is_string($columnHeader) && is_callable($columnHeader)) {
314
                    if ($item->hasMethod($columnSource)) {
315
                        $relObj = $item->{$columnSource}();
316
                    } else {
317
                        $relObj = $item->relObject($columnSource);
318
                    }
319
320
                    $value = $columnHeader($relObj);
321
                } else {
322
                    if (is_string($columnSource)) {
323
                        // It can be a method
324
                        if (strpos($columnSource, '(') !== false) {
325
                            $matches = [];
326
                            preg_match('/([a-zA-Z]*)\((.*)\)/', $columnSource, $matches);
327
                            $func = $matches[1];
328
                            $params = explode(",", $matches[2]);
329
                            // Support only one param for now
330
                            $value = $item->$func($params[0]);
331
                        } else {
332
                            if (array_key_exists($columnSource, $export_format)) {
333
                                $format = $export_format[$columnSource];
334
                                $value = $item->dbObject($columnSource)->$format();
335
                            } else {
336
                                $value = $gridField->getDataFieldValue($item, $columnSource);
337
                            }
338
                        }
339
                    } else {
340
                        // We can also use a simple dot notation
341
                        $parts = explode(".", $columnHeader);
0 ignored issues
show
Bug introduced by
It seems like $columnHeader can also be of type null; however, parameter $string of explode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

341
                        $parts = explode(".", /** @scrutinizer ignore-type */ $columnHeader);
Loading history...
342
                        if (count($parts) == 1) {
343
                            $value = $item->$columnHeader;
344
                        } else {
345
                            $value = $item->relObject($parts[0]);
346
                            if ($value) {
347
                                $relObjField = $parts[1];
348
                                $value = $value->$relObjField;
349
                            }
350
                        }
351
                    }
352
                }
353
354
                // @link https://owasp.org/www-community/attacks/CSV_Injection
355
                // [SS-2017-007] Sanitise XLS executable column values with a leading tab
356
                if ($sanitize && $value && is_string($value)) {
357
                    $value = self::sanitizeValue($value);
358
                }
359
360
                $dataRow[] = $value;
361
            }
362
363
            if ($item->hasMethod('destroy')) {
364
                $item->destroy();
365
            }
366
367
            yield $dataRow;
368
        }
369
    }
370
371
    /**
372
     * Sanitization is necessary for csv
373
     * It can be turned off if needed
374
     * If we have no chars to sanitize, it's not enabled
375
     *
376
     * @return boolean
377
     */
378
    public function isSanitizeEnabled(): bool
379
    {
380
        $sanitize_xls_chars = ExcelImportExport::config()->sanitize_xls_chars ?? "=";
381
        $sanitize = $this->sanitizeXls && $sanitize_xls_chars && $this->exportType == "csv";
382
        return $sanitize;
383
    }
384
385
    /**
386
     * @link https://owasp.org/www-community/attacks/CSV_Injection
387
     * [SS-2017-007] Sanitise XLS executable column values with a leading tab
388
     */
389
    public static function sanitizeValue(string $value = null): ?string
390
    {
391
        if (!$value) {
392
            return $value;
393
        }
394
395
        $sanitize_xls_chars = ExcelImportExport::config()->sanitize_xls_chars ?? "=";
396
        $sanitize_xls_chars_len = strlen($sanitize_xls_chars);
397
398
        // If we have only one char we can make it simpler
399
        if ($sanitize_xls_chars_len === 1) {
400
            if ($value[0] === $sanitize_xls_chars) {
401
                $value = "\t" . $value;
402
            }
403
        } else {
404
            if (preg_match('/^[' . $sanitize_xls_chars . '].*/', $value)) {
405
                $value = "\t" . $value;
406
            }
407
        }
408
        return $value;
409
    }
410
411
    /**
412
     * @return array<int|string,mixed>
413
     */
414
    public function getExportColumns()
415
    {
416
        return $this->exportColumns;
417
    }
418
419
    /**
420
     * @param array<int|string,mixed> $cols
421
     * @return $this
422
     */
423
    public function setExportColumns($cols)
424
    {
425
        $this->exportColumns = $cols;
426
        return $this;
427
    }
428
429
    /**
430
     * @return boolean
431
     */
432
    public function getHasHeader()
433
    {
434
        return $this->hasHeader;
435
    }
436
437
    /**
438
     * @param boolean $bool
439
     * @return $this
440
     */
441
    public function setHasHeader($bool)
442
    {
443
        $this->hasHeader = $bool;
444
        return $this;
445
    }
446
447
    /**
448
     * @return string
449
     */
450
    public function getExportType()
451
    {
452
        return $this->exportType;
453
    }
454
455
    /**
456
     * @param string $exportType xlsx (default), xls or csv
457
     * @return $this
458
     */
459
    public function setExportType($exportType)
460
    {
461
        if (!in_array($exportType, ['xls', 'xlsx', 'csv'])) {
462
            throw new InvalidArgumentException("Export type must be one of : xls, xlsx, csv");
463
        }
464
        $this->exportType = $exportType;
465
        return $this;
466
    }
467
468
    /**
469
     * @return string
470
     */
471
    public function getExportName()
472
    {
473
        return $this->exportName;
474
    }
475
476
    /**
477
     * @param string $exportName
478
     * @return ExcelGridFieldExportButton
479
     */
480
    public function setExportName($exportName)
481
    {
482
        $this->exportName = $exportName;
483
        return $this;
484
    }
485
486
    /**
487
     * @return string
488
     */
489
    public function getButtonTitle()
490
    {
491
        return $this->buttonTitle;
492
    }
493
494
    /**
495
     * @param string $buttonTitle
496
     * @return ExcelGridFieldExportButton
497
     */
498
    public function setButtonTitle($buttonTitle)
499
    {
500
        $this->buttonTitle = $buttonTitle;
501
        return $this;
502
    }
503
504
    /**
505
     *
506
     * @return bool
507
     */
508
    public function getCheckCanView()
509
    {
510
        return $this->checkCanView;
511
    }
512
513
    /**
514
     *
515
     * @param bool $checkCanView
516
     * @return ExcelGridFieldExportButton
517
     */
518
    public function setCheckCanView($checkCanView)
519
    {
520
        $this->checkCanView = $checkCanView;
521
        return $this;
522
    }
523
524
    /**
525
     *
526
     * @return array<mixed>
527
     */
528
    public function getListFilters()
529
    {
530
        return $this->listFilters;
531
    }
532
533
    /**
534
     *
535
     * @param array<mixed> $listFilters
536
     * @return ExcelGridFieldExportButton
537
     */
538
    public function setListFilters($listFilters)
539
    {
540
        $this->listFilters = $listFilters;
541
        return $this;
542
    }
543
544
    /**
545
     *
546
     * @return callable
547
     */
548
    public function getAfterExportCallback()
549
    {
550
        return $this->afterExportCallback;
551
    }
552
553
    /**
554
     *
555
     * @param callable $afterExportCallback
556
     * @return ExcelGridFieldExportButton
557
     */
558
    public function setAfterExportCallback(callable $afterExportCallback)
559
    {
560
        $this->afterExportCallback = $afterExportCallback;
561
        return $this;
562
    }
563
564
    /**
565
     * Get the value of isLimited
566
     */
567
    public function getIsLimited(): bool
568
    {
569
        return $this->isLimited;
570
    }
571
572
    /**
573
     * Set the value of isLimited
574
     *
575
     * @param bool $isLimited
576
     * @return self
577
     */
578
    public function setIsLimited(bool $isLimited)
579
    {
580
        $this->isLimited = $isLimited;
581
        return $this;
582
    }
583
584
    /**
585
     * Get the value of ignoreFilters
586
     */
587
    public function getIgnoreFilters(): bool
588
    {
589
        return $this->ignoreFilters;
590
    }
591
592
    /**
593
     * Set the value of ignoreFilters
594
     *
595
     * @param bool $ignoreFilters
596
     */
597
    public function setIgnoreFilters(bool $ignoreFilters): self
598
    {
599
        $this->ignoreFilters = $ignoreFilters;
600
        return $this;
601
    }
602
603
    /**
604
     * Get the value of sanitizeXls
605
     */
606
    public function getSanitizeXls(): bool
607
    {
608
        return $this->sanitizeXls;
609
    }
610
611
    /**
612
     * Set the value of sanitizeXls
613
     *
614
     * @param bool $sanitizeXls
615
     */
616
    public function setSanitizeXls(bool $sanitizeXls): self
617
    {
618
        $this->sanitizeXls = $sanitizeXls;
619
        return $this;
620
    }
621
}
622