AdminBaseController.php 37.8 KB
Newer Older
1
<?php
2

3
4
namespace Grav\Plugin\Admin;

5
use Grav\Common\Cache;
6
use Grav\Common\Config\Config;
7
use Grav\Common\Data\Data;
8
use Grav\Common\Debugger;
9
10
11
use Grav\Common\Filesystem\Folder;
use Grav\Common\Grav;
use Grav\Common\Media\Interfaces\MediaInterface;
12
use Grav\Common\Page\Interfaces\PageInterface;
13
use Grav\Common\Page\Media;
14
use Grav\Common\Security;
15
use Grav\Common\Uri;
16
use Grav\Common\User\Interfaces\UserInterface;
17
18
19
use Grav\Common\Utils;
use Grav\Common\Plugin;
use Grav\Common\Theme;
20
21
use Grav\Framework\Controller\Traits\ControllerResponseTrait;
use Grav\Framework\RequestHandler\Exception\RequestException;
22
use JsonException;
23
24
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
25
26
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\File;
27
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
28
29
30
31
32
33
34
35

/**
 * Class AdminController
 *
 * @package Grav\Plugin
 */
class AdminBaseController
{
36
37
    use ControllerResponseTrait;

38
    /** @var Grav */
39
    public $grav;
40
    /** @var string */
41
    public $view;
42
    /** @var string */
43
    public $task;
44
    /** @var string */
45
    public $route;
46
    /** @var array */
47
    public $post;
48
    /** @var array|null */
49
    public $data;
50
51
    /** @var array */
    public $blacklist_views = [];
52

53
    /** @var Uri */
54
    protected $uri;
55
    /** @var Admin */
56
    protected $admin;
57
    /** @var string */
58
    protected $redirect;
59
    /** @var int */
60
61
    protected $redirectCode;

62
    /** @var string[] */
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
    protected $upload_errors = [
        0 => 'There is no error, the file uploaded with success',
        1 => 'The uploaded file exceeds the max upload size',
        2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML',
        3 => 'The uploaded file was only partially uploaded',
        4 => 'No file was uploaded',
        6 => 'Missing a temporary folder',
        7 => 'Failed to write file to disk',
        8 => 'A PHP extension stopped the file upload'
    ];

    /**
     * Performs a task.
     *
     * @return bool True if the action was performed successfully.
     */
    public function execute()
    {
81
82
83
84
        if (null === $this->admin) {
            $this->admin = $this->grav['admin'];
        }

85
        // Ignore blacklisted views.
86
87
88
89
        if (in_array($this->view, $this->blacklist_views, true)) {
            return false;
        }

90
91
92
93
94
95
96
97
98
        // Make sure that user is logged into admin.
        if (!$this->admin->authorize()) {
            return false;
        }

        // Always validate nonce.
        if (!$this->validateNonce()) {
            return false;
        }
99
100
101
102
103

        $method = 'task' . ucfirst($this->task);

        if (method_exists($this, $method)) {
            try {
104
105
106
107
108
109
110
                $response = $this->{$method}();
            } catch (RequestException $e) {
                /** @var Debugger $debugger */
                $debugger = $this->grav['debugger'];
                $debugger->addException($e);

                $response = $this->createErrorResponse($e);
111
            } catch (\RuntimeException $e) {
112
113
114
115
116
                /** @var Debugger $debugger */
                $debugger = $this->grav['debugger'];
                $debugger->addException($e);

                $response = true;
117
118
119
                $this->admin->setMessage($e->getMessage(), 'error');
            }
        } else {
120
            $response = $this->grav->fireEvent('onAdminTaskExecute',
121
122
123
                new Event(['controller' => $this, 'method' => $method]));
        }

124
125
126
127
        if ($response instanceof ResponseInterface) {
            $this->close($response);
        }

128
        // Grab redirect parameter.
129
        $redirect = $this->post['_redirect'] ?? null;
130
131
132
133
134
135
136
        unset($this->post['_redirect']);

        // Redirect if requested.
        if ($redirect) {
            $this->setRedirect($redirect);
        }

137
        return $response;
138
139
140
141
142
143
144
145
146
147
148
149
150
151
    }

    protected function validateNonce()
    {
        if (strtolower($_SERVER['REQUEST_METHOD']) === 'post') {
            if (isset($this->post['admin-nonce'])) {
                $nonce = $this->post['admin-nonce'];
            } else {
                $nonce = $this->grav['uri']->param('admin-nonce');
            }

            if (!$nonce || !Utils::verifyNonce($nonce, 'admin-form')) {
                if ($this->task === 'addmedia') {

152
                    $message = sprintf($this->admin::translate('PLUGIN_ADMIN.FILE_TOO_LARGE', null),
153
154
155
156
157
158
159
160
161
162
163
                        ini_get('post_max_size'));

                    //In this case it's more likely that the image is too big than POST can handle. Show message
                    $this->admin->json_response = [
                        'status'  => 'error',
                        'message' => $message
                    ];

                    return false;
                }

164
                $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error');
165
166
                $this->admin->json_response = [
                    'status'  => 'error',
167
                    'message' => $this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN')
168
169
170
171
172
173
174
175
176
                ];

                return false;
            }
            unset($this->post['admin-nonce']);
        } else {
            if ($this->task === 'logout') {
                $nonce = $this->grav['uri']->param('logout-nonce');
                if (null === $nonce || !Utils::verifyNonce($nonce, 'logout-form')) {
177
                    $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'),
178
179
180
                        'error');
                    $this->admin->json_response = [
                        'status'  => 'error',
181
                        'message' => $this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN')
182
183
184
185
186
187
188
                    ];

                    return false;
                }
            } else {
                $nonce = $this->grav['uri']->param('admin-nonce');
                if (null === $nonce || !Utils::verifyNonce($nonce, 'admin-form')) {
189
                    $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'),
190
191
192
                        'error');
                    $this->admin->json_response = [
                        'status'  => 'error',
193
                        'message' => $this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN')
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
                    ];

                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Sets the page redirect.
     *
     * @param string $path The path to redirect to
     * @param int    $code The HTTP redirect code
209
     * @return void
210
211
212
213
214
215
216
     */
    public function setRedirect($path, $code = 303)
    {
        $this->redirect     = $path;
        $this->redirectCode = $code;
    }

tierce's avatar
tierce committed
217
218
219
    /**
     * Sends JSON response and terminates the call.
     *
220
     * @param array $json
tierce's avatar
tierce committed
221
     * @param int $code
222
     * @return never-return
tierce's avatar
tierce committed
223
     */
224
    protected function sendJsonResponse(array $json, $code = 200): void
tierce's avatar
tierce committed
225
226
    {
        // JSON response.
227
        $response = $this->createJsonResponse($json, $code);
tierce's avatar
tierce committed
228

229
230
231
232
233
234
235
236
237
238
        $this->close($response);
    }

    /**
     * @param ResponseInterface $response
     * @return never-return
     */
    protected function close(ResponseInterface $response): void
    {
        $this->grav->close($response);
tierce's avatar
tierce committed
239
240
    }

241
242
243
244
245
246
247
248
    /**
     * Handles ajax upload for files.
     * Stores in a flash object the temporary file and deals with potential file errors.
     *
     * @return bool True if the action was performed.
     */
    public function taskFilesUpload()
    {
249
        if (null === $_FILES || !$this->authorizeTask('upload file', $this->dataPermissions())) {
250
251
252
253
254
255
256
257
258
259
260
261
            return false;
        }

        /** @var Config $config */
        $config   = $this->grav['config'];
        $data     = $this->view === 'pages' ? $this->admin->page(true) : $this->prepareData([]);
        $settings = $data->blueprints()->schema()->getProperty($this->post['name']);
        $settings = (object)array_merge([
            'avoid_overwriting' => false,
            'random_name'       => false,
            'accept'            => ['image/*'],
            'limit'             => 10,
262
            'filesize'          => Utils::getUploadLimit()
263
264
265
266
267
268
269
270
271
272
        ], (array)$settings, ['name' => $this->post['name']]);

        $upload = $this->normalizeFiles($_FILES['data'], $settings->name);

        $filename = $upload->file->name;

        // Handle bad filenames.
        if (!Utils::checkFilename($filename)) {
            $this->admin->json_response = [
                'status'  => 'error',
273
                'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD', null),
274
                    htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'Bad filename')
275
276
277
278
279
280
281
282
            ];

            return false;
        }

        if (!isset($settings->destination)) {
            $this->admin->json_response = [
                'status'  => 'error',
283
                'message' => $this->admin::translate('PLUGIN_ADMIN.DESTINATION_NOT_SPECIFIED', null)
284
285
286
287
288
289
            ];

            return false;
        }

        // Do not use self@ outside of pages
290
        if ($this->view !== 'pages' && in_array($settings->destination, ['@self', 'self@', '@self@'])) {
291
292
            $this->admin->json_response = [
                'status'  => 'error',
293
                'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_PREVENT_SELF', null),
294
                    htmlspecialchars($settings->destination, ENT_QUOTES | ENT_HTML5, 'UTF-8'))
295
296
297
298
299
300
301
302
303
            ];

            return false;
        }

        // Handle errors and breaks without proceeding further
        if ($upload->file->error !== UPLOAD_ERR_OK) {
            $this->admin->json_response = [
                'status'  => 'error',
304
                'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD', null),
305
306
                    htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
                    $this->upload_errors[$upload->file->error])
307
308
309
310
311
312
313
314
315
316
            ];

            return false;
        }

        // Handle file size limits
        $settings->filesize *= 1048576; // 2^20 [MB in Bytes]
        if ($settings->filesize > 0 && $upload->file->size > $settings->filesize) {
            $this->admin->json_response = [
                'status'  => 'error',
317
                'message' => $this->admin::translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT')
318
319
320
321
322
323
324
325
326
327
328
            ];

            return false;
        }

        // Handle Accepted file types
        // Accept can only be mime types (image/png | image/*) or file extensions (.pdf|.jpg)
        $accepted = false;
        $errors   = [];

        // Do not trust mimetype sent by the browser
329
        $mime = Utils::getMimeByFilename($filename);
330
331
332
333
334
335
336
337
338

        foreach ((array)$settings->accept as $type) {
            // Force acceptance of any file when star notation
            if ($type === '*') {
                $accepted = true;
                break;
            }

            $isMime = strstr($type, '/');
339
            $find   = str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type);
340
341
342
343

            if ($isMime) {
                $match = preg_match('#' . $find . '$#', $mime);
                if (!$match) {
344
                    $errors[] = htmlspecialchars('The MIME type "' . $mime . '" for the file "' . $filename . '" is not an accepted.', ENT_QUOTES | ENT_HTML5, 'UTF-8');
345
346
347
348
349
                } else {
                    $accepted = true;
                    break;
                }
            } else {
350
                $match = preg_match('#' . $find . '$#', $filename);
351
                if (!$match) {
352
                    $errors[] = htmlspecialchars('The File Extension for the file "' . $filename . '" is not an accepted.', ENT_QUOTES | ENT_HTML5, 'UTF-8');
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
                } else {
                    $accepted = true;
                    break;
                }
            }
        }

        if (!$accepted) {
            $this->admin->json_response = [
                'status'  => 'error',
                'message' => implode('<br />', $errors)
            ];

            return false;
        }

        // Remove the error object to avoid storing it
        unset($upload->file->error);

        // we need to move the file at this stage or else
        // it won't be available upon save later on
        // since php removes it from the upload location
        $tmp_dir  = Admin::getTempDir();
        $tmp_file = $upload->file->tmp_name;
Thierry Fenasse's avatar
Thierry Fenasse committed
377
        $tmp      = $tmp_dir . '/uploaded-files/' . Utils::basename($tmp_file);
378
379
380
381
382

        Folder::create(dirname($tmp));
        if (!move_uploaded_file($tmp_file, $tmp)) {
            $this->admin->json_response = [
                'status'  => 'error',
383
384
385
386
387
                'message' => sprintf(
                    $this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_MOVE', null),
                    '',
                    htmlspecialchars($tmp, ENT_QUOTES | ENT_HTML5, 'UTF-8')
                )
388
389
390
391
392
            ];

            return false;
        }

393
394
395
396
397
        // Special Sanitization for SVG
        if (Utils::contains($mime, 'svg', false)) {
            Security::sanitizeSVG($tmp);
        }

398
399
400
401
402
        $upload->file->tmp_name = $tmp;

        // Retrieve the current session of the uploaded files for the field
        // and initialize it if it doesn't exist
        $sessionField = base64_encode($this->grav['uri']->url());
403
        $flash        = $this->admin->session()->getFlashObject('files-upload') ?? [];
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
        if (!isset($flash[$sessionField])) {
            $flash[$sessionField] = [];
        }
        if (!isset($flash[$sessionField][$upload->field])) {
            $flash[$sessionField][$upload->field] = [];
        }

        // Set destination
        if ($this->grav['locator']->isStream($settings->destination)) {
            $destination = $this->grav['locator']->findResource($settings->destination, false, true);
        } else {
            $destination = Folder::getRelativePath(rtrim($settings->destination, '/'));
            $destination = $this->admin->getPagePathFromToken($destination);
        }

        // Create destination if needed
        if (!is_dir($destination)) {
            Folder::mkdir($destination);
        }

        // Generate random name if required
        if ($settings->random_name) { // TODO: document
Thierry Fenasse's avatar
Thierry Fenasse committed
426
            $extension          = Utils::pathinfo($upload->file->name, PATHINFO_EXTENSION);
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
            $upload->file->name = Utils::generateRandomString(15) . '.' . $extension;
        }

        // Handle conflicting name if needed
        if ($settings->avoid_overwriting) { // TODO: document
            if (file_exists($destination . '/' . $upload->file->name)) {
                $upload->file->name = date('YmdHis') . '-' . $upload->file->name;
            }
        }

        // Prepare object for later save
        $path               = $destination . '/' . $upload->file->name;
        $upload->file->path = $path;
        // $upload->file->route = $page ? $path : null;

        // Prepare data to be saved later
        $flash[$sessionField][$upload->field][$path] = (array)$upload->file;

        // Finally store the new uploaded file in the field session
        $this->admin->session()->setFlashObject('files-upload', $flash);
        $this->admin->json_response = [
            'status'  => 'success',
            'session' => \json_encode([
                'sessionField' => base64_encode($this->grav['uri']->url()),
                'path'         => $upload->file->path,
                'field'        => $settings->name
            ])
        ];

        return true;
    }

    /**
     * Checks if the user is allowed to perform the given task with its associated permissions
     *
     * @param string $task        The task to execute
     * @param array  $permissions The permissions given
     *
     * @return bool True if authorized. False if not.
     */
    public function authorizeTask($task = '', $permissions = [])
    {
        if (!$this->admin->authorize($permissions)) {
            if ($this->grav['uri']->extension() === 'json') {
                $this->admin->json_response = [
                    'status'  => 'unauthorized',
473
                    'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') . ' ' . $task . '.'
474
475
                ];
            } else {
476
                $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') . ' ' . $task . '.',
477
478
479
480
481
482
483
484
485
                    'error');
            }

            return false;
        }

        return true;
    }

486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
    /**
     * Checks if the user is allowed to perform the given task with its associated permissions.
     * Throws exception if the check fails.
     *
     * @param string $task        The task to execute
     * @param array  $permissions The permissions given
     * @throws RequestException
     */
    public function checkTaskAuthorization($task = '', $permissions = [])
    {
        if (!$this->admin->authorize($permissions)) {
            throw new RequestException($this->getRequest(), $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') . ' ' . $task . '.', 403);
        }
    }

501
502
503
504
505
506
507
508
509
510
511
512
    /**
     * Gets the permissions needed to access a given view
     *
     * @return array An array of permissions
     */
    protected function dataPermissions()
    {
        $type        = $this->view;
        $permissions = ['admin.super'];

        switch ($type) {
            case 'config':
513
514
                $type = $this->route ?: 'system';
                $permissions[] = 'admin.configuration.' . $type;
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
                break;
            case 'plugins':
                $permissions[] = 'admin.plugins';
                break;
            case 'themes':
                $permissions[] = 'admin.themes';
                break;
            case 'users':
                $permissions[] = 'admin.users';
                break;
            case 'user':
                $permissions[] = 'admin.login';
                $permissions[] = 'admin.users';
                break;
            case 'pages':
                $permissions[] = 'admin.pages';
                break;
532
533
534
            default:
                $permissions[] = 'admin.configuration.' . $type;
                $permissions[] = 'admin.configuration_' . $type;
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
        }

        return $permissions;
    }

    /**
     * Gets the configuration data for a given view & post
     *
     * @param array $data
     *
     * @return array
     */
    protected function prepareData(array $data)
    {
        return $data;
    }

    /**
     * Internal method to normalize the $_FILES array
     *
     * @param array  $data $_FILES starting point data
     * @param string $key
     *
     * @return object a new Object with a normalized list of files
     */
    protected function normalizeFiles($data, $key = '')
    {
        $files        = new \stdClass();
        $files->field = $key;
        $files->file  = new \stdClass();

        foreach ($data as $fieldName => $fieldValue) {
            // Since Files Upload are always happening via Ajax
            // we are not interested in handling `multiple="true"`
            // because they are always handled one at a time.
            // For this reason we normalize the value to string,
            // in case it is arriving as an array.
            $value                     = (array)Utils::getDotNotation($fieldValue, $key);
            $files->file->{$fieldName} = array_shift($value);
        }

        return $files;
    }

    /**
     * Removes a file from the flash object session, before it gets saved
     *
     * @return bool True if the action was performed.
     */
    public function taskFilesSessionRemove()
    {
586
        if (!$this->authorizeTask('delete file', $this->dataPermissions())) {
587
588
589
590
591
592
593
594
595
596
597
598
599
600
            return false;
        }

        // Retrieve the current session of the uploaded files for the field
        // and initialize it if it doesn't exist
        $sessionField = base64_encode($this->grav['uri']->url());
        $request      = \json_decode($this->post['session']);

        // Ensure the URI requested matches the current one, otherwise fail
        if ($request->sessionField !== $sessionField) {
            return false;
        }

        // Retrieve the flash object and remove the requested file from it
601
602
        $flash    = $this->admin->session()->getFlashObject('files-upload') ?? [];
        $endpoint = $flash[$request->sessionField][$request->field][$request->path] ?? null;
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640

        if (isset($endpoint)) {
            if (file_exists($endpoint['tmp_name'])) {
                unlink($endpoint['tmp_name']);
            }

            unset($endpoint);
        }

        // Walk backward to cleanup any empty field that's left
        // Field
        if (isset($flash[$request->sessionField][$request->field][$request->path])) {
            unset($flash[$request->sessionField][$request->field][$request->path]);
        }

        // Field
        if (isset($flash[$request->sessionField][$request->field]) && empty($flash[$request->sessionField][$request->field])) {
            unset($flash[$request->sessionField][$request->field]);
        }

        // Session Field
        if (isset($flash[$request->sessionField]) && empty($flash[$request->sessionField])) {
            unset($flash[$request->sessionField]);
        }


        // If there's anything left to restore in the flash object, do so
        if (count($flash)) {
            $this->admin->session()->setFlashObject('files-upload', $flash);
        }

        $this->admin->json_response = ['status' => 'success'];

        return true;
    }

    /**
     * Redirect to the route stored in $this->redirect
641
642
643
644
     *
     * Route may or may not be prefixed by /en or /admin or /en/admin.
     *
     * @return void
645
646
647
     */
    public function redirect()
    {
648
        $this->admin->redirect($this->redirect, $this->redirectCode);
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
    }

    /**
     * Prepare and return POST data.
     *
     * @param array $post
     * @return array
     */
    protected function getPost($post)
    {
        if (!is_array($post)) {
            return [];
        }

        unset($post['task']);

        // Decode JSON encoded fields and merge them to data.
        if (isset($post['_json'])) {
            $post = array_replace_recursive($post, $this->jsonDecode($post['_json']));
            unset($post['_json']);
        }

671
        return $this->cleanDataKeys($post);
672
673
674
675
676
    }

    /**
     * Recursively JSON decode data.
     *
677
     * @param array $data
678
     * @return array
679
680
     * @throws JsonException
     * @internal Do not use directly!
681
     */
682
    protected function jsonDecode(array $data): array
683
684
685
686
687
    {
        foreach ($data as &$value) {
            if (is_array($value)) {
                $value = $this->jsonDecode($value);
            } else {
688
                $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
689
690
691
692
693
694
            }
        }

        return $data;
    }

695
696
697
    /**
     * @param array $source
     * @return array
698
     * @internal Do not use directly!
699
     */
700
    protected function cleanDataKeys(array $source): array
701
702
    {
        $out = [];
703
704
705
706
707
708
        foreach ($source as $key => $value) {
            $key = str_replace(['%5B', '%5D'], ['[', ']'], $key);
            if (is_array($value)) {
                $out[$key] = $this->cleanDataKeys($value);
            } else {
                $out[$key] = $value;
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
            }
        }

        return $out;
    }

    /**
     * Return true if multilang is active
     *
     * @return bool True if multilang is active
     */
    protected function isMultilang()
    {
        return count($this->grav['config']->get('system.languages.supported', [])) > 1;
    }

    /**
726
     * @param PageInterface|UserInterface|Data $obj
727
     *
728
     * @return PageInterface|UserInterface|Data
729
730
731
732
733
734
735
     */
    protected function storeFiles($obj)
    {
        // Process previously uploaded files for the current URI
        // and finally store them. Everything else will get discarded
        $queue = $this->admin->session()->getFlashObject('files-upload');
        if (is_array($queue)) {
736
            $queue = $queue[base64_encode($this->grav['uri']->url())];
737
738
739
740
741
742
743
744
745
746
747
748
749
750
            foreach ($queue as $key => $files) {
                foreach ($files as $destination => $file) {
                    if (!rename($file['tmp_name'], $destination)) {
                        throw new \RuntimeException(sprintf($this->admin->translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_MOVE',
                            null), '"' . $file['tmp_name'] . '"', $destination));
                    }

                    unset($files[$destination]['tmp_name']);
                }

                if ($this->view === 'pages') {
                    $keys     = explode('.', preg_replace('/^header./', '', $key));
                    $init_key = array_shift($keys);
                    if (count($keys) > 0) {
751
                        $new_data = $obj->header()->{$init_key} ?? [];
752
753
754
755
756
757
758
759
760
761
                        Utils::setDotNotation($new_data, implode('.', $keys), $files, true);
                    } else {
                        $new_data = $files;
                    }
                    if (isset($obj->header()->{$init_key})) {
                        $obj->modifyHeader($init_key,
                            array_replace_recursive([], $obj->header()->{$init_key}, $new_data));
                    } else {
                        $obj->modifyHeader($init_key, $new_data);
                    }
762
763
                } elseif ($obj instanceof UserInterface and $key === 'avatar') {
                    $obj->set($key, $files);
764
765
766
767
768
769
770
771
772
773
774
775
776
                } else {
                    // TODO: [this is JS handled] if it's single file, remove existing and use set, if it's multiple, use join
                    $obj->join($key, $files); // stores
                }

            }
        }

        return $obj;
    }

    /**
     * Used by the filepicker field to get a list of files in a folder.
777
778
     *
     * @return bool
779
780
781
     */
    protected function taskGetFilesInFolder()
    {
782
        if (!$this->authorizeTask('get files', $this->dataPermissions())) {
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
            return false;
        }

        $data = $this->view === 'pages' ? $this->admin->page(true) : $this->prepareData([]);

        if (null === $data) {
            return false;
        }

        if (method_exists($data, 'blueprints')) {
            $settings = $data->blueprints()->schema()->getProperty($this->post['name']);
        } elseif (method_exists($data, 'getBlueprint')) {
            $settings = $data->getBlueprint()->schema()->getProperty($this->post['name']);
        }

        if (isset($settings['folder'])) {
            $folder = $settings['folder'];
        } else {
            $folder = 'self@';
        }

        // Do not use self@ outside of pages
        if ($this->view !== 'pages' && in_array($folder, ['@self', 'self@', '@self@'])) {
            if (!$data instanceof MediaInterface) {
                $this->admin->json_response = [
                    'status'  => 'error',
809
                    'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_PREVENT_SELF', null), $folder)
810
811
812
813
814
815
816
                ];

                return false;
            }

            $media = $data->getMedia();
        } else {
817
818
819
820
821
822
            /** @var UniformResourceLocator $locator */
            $locator = $this->grav['locator'];
            if ($locator->isStream($folder)) {
                $folder = $locator->findResource($folder);
            }

823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
            // Set destination
            $folder = Folder::getRelativePath(rtrim($folder, '/'));
            $folder = $this->admin->getPagePathFromToken($folder);

            $media = new Media($folder);
        }

        $available_files = [];
        $metadata = [];
        $thumbs = [];


        foreach ($media->all() as $name => $medium) {

           $available_files[] = $name;

            if (isset($settings['include_metadata'])) {
                $img_metadata = $medium->metadata();
                if ($img_metadata) {
                    $metadata[$name] = $img_metadata;
                }
            }

        }

        // Peak in the flashObject for optimistic filepicker updates
        $pending_files = [];
        $sessionField  = base64_encode($this->grav['uri']->url());
        $flash         = $this->admin->session()->getFlashObject('files-upload');

        if ($flash && isset($flash[$sessionField])) {
            foreach ($flash[$sessionField] as $field => $data) {
                foreach ($data as $file) {
                    if (dirname($file['path']) === $folder) {
                        $pending_files[] = $file['name'];
                    }
                }
            }
        }

        $this->admin->session()->setFlashObject('files-upload', $flash);

        // Handle Accepted file types
        // Accept can only be file extensions (.pdf|.jpg)
        if (isset($settings['accept'])) {
            $available_files = array_filter($available_files, function ($file) use ($settings) {
                return $this->filterAcceptedFiles($file, $settings);
            });

            $pending_files = array_filter($pending_files, function ($file) use ($settings) {
                return $this->filterAcceptedFiles($file, $settings);
            });
        }

        // Generate thumbs if needed
        if (isset($settings['preview_images']) && $settings['preview_images'] === true) {
            foreach ($available_files as $filename) {
                $thumbs[$filename] = $media[$filename]->zoomCrop(100,100)->url();
            }
        }

        $this->admin->json_response = [
            'status'  => 'success',
            'files'   => array_values($available_files),
            'pending' => array_values($pending_files),
            'folder'  => $folder,
            'metadata' => $metadata,
            'thumbs' => $thumbs
        ];

        return true;
    }

896
897
898
899
900
    /**
     * @param string $file
     * @param array $settings
     * @return false
     */
901
902
903
904
905
906
    protected function filterAcceptedFiles($file, $settings)
    {
        $valid = false;

        foreach ((array)$settings['accept'] as $type) {
            $find = str_replace('*', '.*', $type);
907
            $valid |= preg_match('#' . $find . '$#i', $file);
908
909
910
911
912
913
914
915
916
917
918
919
        }

        return $valid;
    }

    /**
     * Handle deleting a file from a blueprint
     *
     * @return bool True if the action was performed.
     */
    protected function taskRemoveFileFromBlueprint()
    {
920
921
922
923
        if (!$this->authorizeTask('remove file', $this->dataPermissions())) {
            return false;
        }

924
        /** @var Uri $uri */
925
926
927
        $uri       = $this->grav['uri'];
        $blueprint = base64_decode($uri->param('blueprint'));
        $path      = base64_decode($uri->param('path'));
928
        $route     = base64_decode($uri->param('proute'));
929
930
931
        $type      = $uri->param('type');
        $field     = $uri->param('field');

Thierry Fenasse's avatar
Thierry Fenasse committed
932
        $filename  = Utils::basename($this->post['filename'] ?? '');
933
934
935
936
937
938
939
940
941
        if ($filename === '') {
           $this->admin->json_response = [
                'status'  => 'error',
                'message' => 'Filename is empty'
            ];

            return false;
        }

942
        // Get Blueprint
943
        if ($type === 'pages' || strpos($blueprint, 'pages/') === 0) {
944
            $page = $this->admin->page(true, $route);
945
946
947
948
949
950
951
952
953
954
955
956
957
            if (!$page) {
                $this->admin->json_response = [
                    'status'  => 'error',
                    'message' => 'Page not found'
                ];

                return false;
            }
            $blueprints = $page->blueprints();
            $path = Folder::getRelativePath($page->path());
            $settings = (object)$blueprints->schema()->getProperty($field);
        } else {
            $page = null;
958
            if ($type === 'themes' || $type === 'plugins') {
959
960
                $obj = $this->grav[$type]->get(Utils::substrToString($blueprint, '/')); //here
                $settings = (object) $obj->blueprints()->schema()->getProperty($field);
961
            } else {
962
                $settings = (object)$this->admin->blueprints($blueprint)->schema()->getProperty($field);
963
            }
964
        }
965
966
967
968

        // Get destination
        if ($this->grav['locator']->isStream($settings->destination)) {
            $destination = $this->grav['locator']->findResource($settings->destination, false, true);
969

970
971
        } else {
            $destination = Folder::getRelativePath(rtrim($settings->destination, '/'));
972
            $destination = $this->admin->getPagePathFromToken($destination, $page);
973
974
975
976
977
978
979
980
981
982
983
984
985
        }

        // Not in path
        if (!Utils::startsWith($path, $destination)) {
            $this->admin->json_response = [
                'status'  => 'error',
                'message' => 'Path not valid for this data type'
            ];

            return false;
        }

        // Only remove files from correct destination...
986
        $this->taskRemoveMedia($destination . '/' . $filename);
987

988
        if ($page) {
989
990
            $keys = explode('.', preg_replace('/^header./', '', $field));
            $header = (array)$page->header();
991
            $data_path = implode('.', $keys);
992
            $data = Utils::getDotNotation($header, $data_path);
993
994
995
996
997
998
999
1000

            if (isset($data[$path])) {
                unset($data[$path]);
                Utils::setDotNotation($header, $data_path, $data);
                $page->header($header);
            }

            $page->save();
For faster browsing, not all history is shown. View entire blame