Completed
Push — master ( 28dfb2...02612b )
by Jordi Sala
17s queued 13s
created

src/Twig/Extension/SonataAdminExtension.php (6 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the Sonata Project package.
7
 *
8
 * (c) Thomas Rabaix <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Sonata\AdminBundle\Twig\Extension;
15
16
use Doctrine\Common\Util\ClassUtils;
17
use Psr\Log\LoggerInterface;
18
use Sonata\AdminBundle\Admin\AdminInterface;
19
use Sonata\AdminBundle\Admin\FieldDescriptionInterface;
20
use Sonata\AdminBundle\Admin\Pool;
21
use Sonata\AdminBundle\Exception\NoValueException;
22
use Sonata\AdminBundle\Templating\TemplateRegistryInterface;
23
use Symfony\Component\DependencyInjection\ContainerInterface;
24
use Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException;
25
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
26
use Symfony\Component\Security\Acl\Voter\FieldVote;
27
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
28
use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
29
use Symfony\Contracts\Translation\TranslatorInterface;
30
use Twig\Environment;
31
use Twig\Error\LoaderError;
32
use Twig\Extension\AbstractExtension;
33
use Twig\TemplateWrapper;
34
use Twig\TwigFilter;
35
use Twig\TwigFunction;
36
37
/**
38
 * @final since sonata-project/admin-bundle 3.52
39
 *
40
 * @author Thomas Rabaix <[email protected]>
41
 */
42
final class SonataAdminExtension extends AbstractExtension
43
{
44
    // @todo: there are more locales which are not supported by moment and they need to be translated/normalized/canonicalized here
45
    public const MOMENT_UNSUPPORTED_LOCALES = [
46
        'de' => ['de', 'de-at'],
47
        'es' => ['es', 'es-do'],
48
        'nl' => ['nl', 'nl-be'],
49
        'fr' => ['fr', 'fr-ca', 'fr-ch'],
50
    ];
51
52
    /**
53
     * @var TranslatorInterface|null
54
     */
55
    protected $translator;
56
    /**
57
     * @var Pool
58
     */
59
    private $pool;
60
61
    /**
62
     * @var LoggerInterface
63
     */
64
    private $logger;
65
66
    /**
67
     * @var string[]
68
     */
69
    private $xEditableTypeMapping = [];
70
71
    /**
72
     * @var ContainerInterface
73
     */
74
    private $templateRegistries;
75
76
    /**
77
     * @var AuthorizationCheckerInterface
78
     */
79
    private $securityChecker;
80
81
    public function __construct(
82
        Pool $pool,
83
        ?LoggerInterface $logger = null,
84
        TranslatorInterface $translator,
85
        ?ContainerInterface $templateRegistries = null,
86
        ?AuthorizationCheckerInterface $securityChecker = null
87
    ) {
88
        $this->pool = $pool;
89
        $this->logger = $logger;
90
        $this->translator = $translator;
91
        $this->templateRegistries = $templateRegistries;
92
        $this->securityChecker = $securityChecker;
93
    }
94
95
    public function getFilters()
96
    {
97
        return [
98
            new TwigFilter(
99
                'render_list_element',
100
                [$this, 'renderListElement'],
101
                [
102
                    'is_safe' => ['html'],
103
                    'needs_environment' => true,
104
                ]
105
            ),
106
            new TwigFilter(
107
                'render_view_element',
108
                [$this, 'renderViewElement'],
109
                [
110
                    'is_safe' => ['html'],
111
                    'needs_environment' => true,
112
                ]
113
            ),
114
            new TwigFilter(
115
                'render_view_element_compare',
116
                [$this, 'renderViewElementCompare'],
117
                [
118
                    'is_safe' => ['html'],
119
                    'needs_environment' => true,
120
                ]
121
            ),
122
            new TwigFilter(
123
                'render_relation_element',
124
                [$this, 'renderRelationElement']
125
            ),
126
            new TwigFilter(
127
                'sonata_urlsafeid',
128
                [$this, 'getUrlSafeIdentifier']
129
            ),
130
            new TwigFilter(
131
                'sonata_xeditable_type',
132
                [$this, 'getXEditableType']
133
            ),
134
            new TwigFilter(
135
                'sonata_xeditable_choices',
136
                [$this, 'getXEditableChoices']
137
            ),
138
        ];
139
    }
140
141
    public function getFunctions()
142
    {
143
        return [
144
            new TwigFunction('canonicalize_locale_for_moment', [$this, 'getCanonicalizedLocaleForMoment'], ['needs_context' => true]),
145
            new TwigFunction('canonicalize_locale_for_select2', [$this, 'getCanonicalizedLocaleForSelect2'], ['needs_context' => true]),
146
            new TwigFunction('is_granted_affirmative', [$this, 'isGrantedAffirmative']),
147
        ];
148
    }
149
150
    public function getName()
151
    {
152
        return 'sonata_admin';
153
    }
154
155
    /**
156
     * render a list element from the FieldDescription.
157
     *
158
     * @param object|array $listElement
159
     * @param array        $params
160
     *
161
     * @return string
162
     */
163
    public function renderListElement(
164
        Environment $environment,
165
        $listElement,
166
        FieldDescriptionInterface $fieldDescription,
167
        $params = []
168
    ) {
169
        $template = $this->getTemplate(
170
            $fieldDescription,
171
            $this->getTemplateRegistry($fieldDescription->getAdmin()->getCode())->getTemplate('base_list_field'),
172
            $environment
173
        );
174
175
        [$object, $value] = $this->getObjectAndValueFromListElement($listElement, $fieldDescription);
0 ignored issues
show
The variable $object does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
The variable $value does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
176
177
        return $this->render($fieldDescription, $template, array_merge($params, [
178
            'admin' => $fieldDescription->getAdmin(),
179
            'object' => $object,
180
            'value' => $value,
181
            'field_description' => $fieldDescription,
182
        ]), $environment);
183
    }
184
185
    /**
186
     * render a view element.
187
     *
188
     * @param object $object
189
     *
190
     * @return string
191
     */
192
    public function renderViewElement(
193
        Environment $environment,
194
        FieldDescriptionInterface $fieldDescription,
195
        $object
196
    ) {
197
        $template = $this->getTemplate(
198
            $fieldDescription,
199
            '@SonataAdmin/CRUD/base_show_field.html.twig',
200
            $environment
201
        );
202
203
        try {
204
            $value = $fieldDescription->getValue($object);
205
        } catch (NoValueException $e) {
206
            // NEXT_MAJOR: Remove the try catch in order to throw the NoValueException.
207
            @trigger_error(
208
                'Accessing a non existing value is deprecated'
209
                .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.',
210
                E_USER_DEPRECATED
211
            );
212
213
            $value = null;
214
        }
215
216
        return $this->render($fieldDescription, $template, [
217
            'field_description' => $fieldDescription,
218
            'object' => $object,
219
            'value' => $value,
220
            'admin' => $fieldDescription->getAdmin(),
221
        ], $environment);
222
    }
223
224
    /**
225
     * render a compared view element.
226
     *
227
     * @param mixed $baseObject
228
     * @param mixed $compareObject
229
     *
230
     * @return string
231
     */
232
    public function renderViewElementCompare(
233
        Environment $environment,
234
        FieldDescriptionInterface $fieldDescription,
235
        $baseObject,
236
        $compareObject
237
    ) {
238
        $template = $this->getTemplate(
239
            $fieldDescription,
240
            '@SonataAdmin/CRUD/base_show_field.html.twig',
241
            $environment
242
        );
243
244
        try {
245
            $baseValue = $fieldDescription->getValue($baseObject);
246
        } catch (NoValueException $e) {
247
            // NEXT_MAJOR: Remove the try catch in order to throw the NoValueException.
248
            @trigger_error(
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
249
                'Accessing a non existing value is deprecated'
250
                .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.',
251
                E_USER_DEPRECATED
252
            );
253
254
            $baseValue = null;
255
        }
256
257
        try {
258
            $compareValue = $fieldDescription->getValue($compareObject);
259
        } catch (NoValueException $e) {
260
            // NEXT_MAJOR: Remove the try catch in order to throw the NoValueException.
261
            @trigger_error(
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
262
                'Accessing a non existing value is deprecated'
263
                .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.',
264
                E_USER_DEPRECATED
265
            );
266
267
            $compareValue = null;
268
        }
269
270
        $baseValueOutput = $template->render([
271
            'admin' => $fieldDescription->getAdmin(),
272
            'field_description' => $fieldDescription,
273
            'value' => $baseValue,
274
            'object' => $baseObject,
275
        ]);
276
277
        $compareValueOutput = $template->render([
278
            'field_description' => $fieldDescription,
279
            'admin' => $fieldDescription->getAdmin(),
280
            'value' => $compareValue,
281
            'object' => $compareObject,
282
        ]);
283
284
        // Compare the rendered output of both objects by using the (possibly) overridden field block
285
        $isDiff = $baseValueOutput !== $compareValueOutput;
286
287
        return $this->render($fieldDescription, $template, [
288
            'field_description' => $fieldDescription,
289
            'value' => $baseValue,
290
            'value_compare' => $compareValue,
291
            'is_diff' => $isDiff,
292
            'admin' => $fieldDescription->getAdmin(),
293
            'object' => $baseObject,
294
        ], $environment);
295
    }
296
297
    /**
298
     * @param mixed $element
299
     *
300
     * @throws \RuntimeException
301
     *
302
     * @return mixed
303
     */
304
    public function renderRelationElement($element, FieldDescriptionInterface $fieldDescription)
305
    {
306
        if (!\is_object($element)) {
307
            return $element;
308
        }
309
310
        $propertyPath = $fieldDescription->getOption('associated_property');
311
312
        if (null === $propertyPath) {
313
            // For BC kept associated_tostring option behavior
314
            $method = $fieldDescription->getOption('associated_tostring');
315
316
            if ($method) {
317
                @trigger_error(
318
                    'Option "associated_tostring" is deprecated since version 2.3 and will be removed in 4.0. Use "associated_property" instead.',
319
                    E_USER_DEPRECATED
320
                );
321
            } else {
322
                $method = '__toString';
323
            }
324
325
            if (!method_exists($element, $method)) {
326
                throw new \RuntimeException(sprintf(
327
                    'You must define an `associated_property` option or create a `%s::__toString` method'
328
                    .' to the field option %s from service %s is ',
329
                    \get_class($element),
330
                    $fieldDescription->getName(),
331
                    $fieldDescription->getAdmin()->getCode()
332
                ));
333
            }
334
335
            return $element->$method();
336
        }
337
338
        if (\is_callable($propertyPath)) {
339
            return $propertyPath($element);
340
        }
341
342
        return $this->pool->getPropertyAccessor()->getValue($element, $propertyPath);
343
    }
344
345
    /**
346
     * Get the identifiers as a string that is safe to use in a url.
347
     *
348
     * @param object $model
349
     *
350
     * @return string string representation of the id that is safe to use in a url
351
     */
352
    public function getUrlSafeIdentifier($model, ?AdminInterface $admin = null)
353
    {
354
        if (null === $admin) {
355
            $class = ClassUtils::getClass($model);
356
            if (!$this->pool->hasAdminByClass($class)) {
357
                throw new \InvalidArgumentException('You must pass an admin.');
358
            }
359
360
            $admin = $this->pool->getAdminByClass($class);
361
        }
362
363
        return $admin->getUrlSafeIdentifier($model);
364
    }
365
366
    /**
367
     * @param string[] $xEditableTypeMapping
368
     */
369
    public function setXEditableTypeMapping($xEditableTypeMapping): void
370
    {
371
        $this->xEditableTypeMapping = $xEditableTypeMapping;
372
    }
373
374
    /**
375
     * @return string|bool
376
     */
377
    public function getXEditableType($type)
378
    {
379
        return isset($this->xEditableTypeMapping[$type]) ? $this->xEditableTypeMapping[$type] : false;
380
    }
381
382
    /**
383
     * Return xEditable choices based on the field description choices options & catalogue options.
384
     * With the following choice options:
385
     *     ['Status1' => 'Alias1', 'Status2' => 'Alias2']
386
     * The method will return:
387
     *     [['value' => 'Status1', 'text' => 'Alias1'], ['value' => 'Status2', 'text' => 'Alias2']].
388
     *
389
     * @return array
390
     */
391
    public function getXEditableChoices(FieldDescriptionInterface $fieldDescription)
392
    {
393
        $choices = $fieldDescription->getOption('choices', []);
394
        $catalogue = $fieldDescription->getOption('catalogue');
395
        $xEditableChoices = [];
396
        if (!empty($choices)) {
397
            reset($choices);
398
            $first = current($choices);
399
            // the choices are already in the right format
400
            if (\is_array($first) && \array_key_exists('value', $first) && \array_key_exists('text', $first)) {
401
                $xEditableChoices = $choices;
402
            } else {
403
                foreach ($choices as $value => $text) {
404
                    if ($catalogue) {
405
                        if (null !== $this->translator) {
406
                            $text = $this->translator->trans($text, [], $catalogue);
407
                        // NEXT_MAJOR: Remove this check
408
                        } elseif (method_exists($fieldDescription->getAdmin(), 'trans')) {
409
                            $text = $fieldDescription->getAdmin()->trans($text, [], $catalogue);
410
                        }
411
                    }
412
413
                    $xEditableChoices[] = [
414
                        'value' => $value,
415
                        'text' => $text,
416
                    ];
417
                }
418
            }
419
        }
420
421
        if (false === $fieldDescription->getOption('required', true)
422
            && false === $fieldDescription->getOption('multiple', false)
423
        ) {
424
            $xEditableChoices = array_merge([[
425
                'value' => '',
426
                'text' => '',
427
            ]], $xEditableChoices);
428
        }
429
430
        return $xEditableChoices;
431
    }
432
433
    /*
434
     * Returns a canonicalized locale for "moment" NPM library,
435
     * or `null` if the locale's language is "en", which doesn't require localization.
436
     *
437
     * @return string|null
438
     */
439
    public function getCanonicalizedLocaleForMoment(array $context)
440
    {
441
        $locale = strtolower(str_replace('_', '-', $context['app']->getRequest()->getLocale()));
442
443
        // "en" language doesn't require localization.
444
        if (('en' === $lang = substr($locale, 0, 2)) && !\in_array($locale, ['en-au', 'en-ca', 'en-gb', 'en-ie', 'en-nz'], true)) {
445
            return null;
446
        }
447
448
        foreach (self::MOMENT_UNSUPPORTED_LOCALES as $language => $locales) {
449
            if ($language === $lang && !\in_array($locale, $locales, true)) {
450
                $locale = $language;
451
            }
452
        }
453
454
        return $locale;
455
    }
456
457
    /**
458
     * Returns a canonicalized locale for "select2" NPM library,
459
     * or `null` if the locale's language is "en", which doesn't require localization.
460
     *
461
     * @return string|null
462
     */
463
    public function getCanonicalizedLocaleForSelect2(array $context)
464
    {
465
        $locale = str_replace('_', '-', $context['app']->getRequest()->getLocale());
466
467
        // "en" language doesn't require localization.
468
        if ('en' === $lang = substr($locale, 0, 2)) {
469
            return null;
470
        }
471
472
        switch ($locale) {
473
            case 'pt':
474
                $locale = 'pt-PT';
475
                break;
476
            case 'ug':
477
                $locale = 'ug-CN';
478
                break;
479
            case 'zh':
480
                $locale = 'zh-CN';
481
                break;
482
            default:
483
                if (!\in_array($locale, ['pt-BR', 'pt-PT', 'ug-CN', 'zh-CN', 'zh-TW'], true)) {
484
                    $locale = $lang;
485
                }
486
        }
487
488
        return $locale;
489
    }
490
491
    /**
492
     * @param string|array $role
493
     * @param object|null  $object
494
     * @param string|null  $field
495
     *
496
     * @return bool
497
     */
498
    public function isGrantedAffirmative($role, $object = null, $field = null)
499
    {
500
        if (null === $this->securityChecker) {
501
            return false;
502
        }
503
504
        if (null !== $field) {
505
            $object = new FieldVote($object, $field);
506
        }
507
508
        if (!\is_array($role)) {
509
            $role = [$role];
510
        }
511
512
        foreach ($role as $oneRole) {
513
            try {
514
                if ($this->securityChecker->isGranted($oneRole, $object)) {
515
                    return true;
516
                }
517
            } catch (AuthenticationCredentialsNotFoundException $e) {
518
                // empty on purpose
519
            }
520
        }
521
522
        return false;
523
    }
524
525
    /**
526
     * return the value related to FieldDescription, if the associated object does no
527
     * exists => a temporary one is created.
528
     *
529
     * @param object $object
530
     *
531
     * @throws \RuntimeException
532
     *
533
     * @return mixed
534
     */
535
    private function getValueFromFieldDescription(
536
        $object,
537
        FieldDescriptionInterface $fieldDescription,
538
        array $params = []
539
    ) {
540
        if (isset($params['loop']) && $object instanceof \ArrayAccess) {
541
            throw new \RuntimeException('remove the loop requirement');
542
        }
543
544
        $value = null;
545
546
        try {
547
            $value = $fieldDescription->getValue($object);
548
        } catch (NoValueException $e) {
549
            if ($fieldDescription->getAssociationAdmin()) {
550
                $value = $fieldDescription->getAssociationAdmin()->getNewInstance();
551
            }
552
        }
553
554
        return $value;
555
    }
556
557
    /**
558
     * Get template.
559
     *
560
     * @param string $defaultTemplate
561
     *
562
     * @return TemplateWrapper
563
     */
564
    private function getTemplate(
565
        FieldDescriptionInterface $fieldDescription,
566
        $defaultTemplate,
567
        Environment $environment
568
    ) {
569
        $templateName = $fieldDescription->getTemplate() ?: $defaultTemplate;
570
571
        try {
572
            $template = $environment->load($templateName);
573
        } catch (LoaderError $e) {
574
            @trigger_error(sprintf(
575
                'Relying on default template loading on field template loading exception is deprecated since 3.1'
576
                .' and will be removed in 4.0. A %s exception will be thrown instead',
577
                LoaderError::class
578
            ), E_USER_DEPRECATED);
579
            $template = $environment->load($defaultTemplate);
580
581
            if (null !== $this->logger) {
582
                $this->logger->warning(sprintf(
583
                    'An error occured trying to load the template "%s" for the field "%s",'
584
                    .' the default template "%s" was used instead.',
585
                    $templateName,
586
                    $fieldDescription->getFieldName(),
587
                    $defaultTemplate
588
                ), ['exception' => $e]);
589
            }
590
        }
591
592
        return $template;
593
    }
594
595
    private function render(
596
        FieldDescriptionInterface $fieldDescription,
597
        TemplateWrapper $template,
598
        array $parameters,
599
        Environment $environment
600
    ): ?string {
601
        $content = $template->render($parameters);
602
603
        if ($environment->isDebug()) {
604
            $commentTemplate = <<<'EOT'
605
606
<!-- START
607
    fieldName: %s
608
    template: %s
609
    compiled template: %s
610
    -->
611
    %s
612
<!-- END - fieldName: %s -->
613
EOT;
614
615
            return sprintf(
616
                $commentTemplate,
617
                $fieldDescription->getFieldName(),
618
                $fieldDescription->getTemplate(),
619
                $template->getSourceContext()->getName(),
620
                $content,
621
                $fieldDescription->getFieldName()
622
            );
623
        }
624
625
        return $content;
626
    }
627
628
    /**
629
     * @throws ServiceCircularReferenceException
630
     * @throws ServiceNotFoundException
631
     */
632
    private function getTemplateRegistry(string $adminCode): TemplateRegistryInterface
633
    {
634
        $serviceId = $adminCode.'.template_registry';
635
        $templateRegistry = $this->templateRegistries->get($serviceId);
636
637
        if ($templateRegistry instanceof TemplateRegistryInterface) {
638
            return $templateRegistry;
639
        }
640
641
        throw new ServiceNotFoundException($serviceId);
642
    }
643
644
    /**
645
     * Extracts the object and requested value from the $listElement.
646
     *
647
     * @param object|array $listElement
648
     *
649
     * @throws \TypeError when $listElement is not an object or an array with an object on offset 0
650
     *
651
     * @return array An array containing object and value
652
     */
653
    private function getObjectAndValueFromListElement(
654
        $listElement,
655
        FieldDescriptionInterface $fieldDescription
656
    ): array {
657
        if (\is_object($listElement)) {
658
            $object = $listElement;
659
        } elseif (\is_array($listElement)) {
660
            if (!isset($listElement[0]) || !\is_object($listElement[0])) {
661
                throw new \TypeError(sprintf('If argument 1 passed to %s() is an array it must contain an object at offset 0.', __METHOD__));
0 ignored issues
show
The call to TypeError::__construct() has too many arguments starting with sprintf('If argument 1 p...offset 0.', __METHOD__).

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
662
            }
663
664
            $object = $listElement[0];
665
        } else {
666
            throw new \TypeError(sprintf('Argument 1 passed to %s() must be an object or an array, %s given.', __METHOD__, \gettype($listElement)));
0 ignored issues
show
The call to TypeError::__construct() has too many arguments starting with sprintf('Argument 1 pass...\gettype($listElement)).

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
667
        }
668
669
        if (\is_array($listElement) && \array_key_exists($fieldDescription->getName(), $listElement)) {
670
            $value = $listElement[$fieldDescription->getName()];
671
        } else {
672
            try {
673
                $value = $fieldDescription->getValue($object);
674
            } catch (NoValueException $e) {
675
                // NEXT_MAJOR: throw the NoValueException.
676
                @trigger_error(
677
                    'Accessing a non existing value is deprecated'
678
                    .' since sonata-project/admin-bundle 3.67 and will throw an exception in 4.0.',
679
                    E_USER_DEPRECATED
680
                );
681
682
                $value = null;
683
            }
684
        }
685
686
        return [$object, $value];
687
    }
688
}
689