diff --git a/config/packages/fos_ckeditor.yaml b/config/packages/fos_ckeditor.yaml index a88e43a8..1d114656 100644 --- a/config/packages/fos_ckeditor.yaml +++ b/config/packages/fos_ckeditor.yaml @@ -21,7 +21,7 @@ fos_ck_editor: height: 100 enterMode: 2 toolbar: label_toolbar - extraPlugins: "partdb_label" + extraPlugins: ["partdb_label", "showprotected"] font_names: > DejaVu Sans Mono/DejaVu Sans Mono; DejaVu Sans/DejaVu Sans; @@ -43,6 +43,9 @@ fos_ck_editor: partdb_label: path: "ckeditor/plugins/partdb_label/" filename: "plugin.js" + showprotected: + path: "ckeditor/plugins/showprotected/" + filename: "plugin.js" toolbars: diff --git a/public/ckeditor/plugins/partdb_label/plugin.js b/public/ckeditor/plugins/partdb_label/plugin.js index e04befb1..a784254a 100644 --- a/public/ckeditor/plugins/partdb_label/plugin.js +++ b/public/ckeditor/plugins/partdb_label/plugin.js @@ -83,6 +83,10 @@ function findLabelForPlaceholder(search) } } +//Dont escape text inside of twig blocks +CKEDITOR.config.protectedSource.push(/\{\{[\s\S]*?\}\}/g); +CKEDITOR.config.protectedSource.push(/\{\%[\s\S]*?%\}/g); + CKEDITOR.plugins.add('partdb_label', { hidpi: true, icons: 'placeholder', diff --git a/public/ckeditor/plugins/showprotected/dialogs/protected.js b/public/ckeditor/plugins/showprotected/dialogs/protected.js new file mode 100644 index 00000000..9c7ede3d --- /dev/null +++ b/public/ckeditor/plugins/showprotected/dialogs/protected.js @@ -0,0 +1,52 @@ + +CKEDITOR.dialog.add( 'showProtectedDialog', function( editor ) { + + return { + title: 'Edit Protected Source', + minWidth: 300, + minHeight: 60, + onOk: function() { + var newSourceValue = this.getContentElement( 'info', 'txtProtectedSource' ).getValue(); + + var encodedSourceValue = CKEDITOR.plugins.showprotected.encodeProtectedSource( newSourceValue ); + + this._.selectedElement.setAttribute('data-cke-realelement', encodedSourceValue); + this._.selectedElement.setAttribute('title', newSourceValue); + this._.selectedElement.setAttribute('alt', newSourceValue); + }, + + onHide: function() { + delete this._.selectedElement; + }, + + onShow: function() { + this._.selectedElement = editor.getSelection().getSelectedElement(); + + var decodedSourceValue = CKEDITOR.plugins.showprotected.decodeProtectedSource( this._.selectedElement.getAttribute('data-cke-realelement') ); + + this.setValueOf( 'info', 'txtProtectedSource', decodedSourceValue ); + }, + contents: [ + { + id: 'info', + label: 'Edit Protected Source', + accessKey: 'I', + elements: [ + { + type: 'text', + id: 'txtProtectedSource', + label: 'Value', + required: true, + validate: function() { + if ( !this.getValue() ) { + alert( 'The value cannot be empty' ); + return false; + } + return true; + } + } + ] + } + ] + }; +} ); \ No newline at end of file diff --git a/public/ckeditor/plugins/showprotected/images/code.gif b/public/ckeditor/plugins/showprotected/images/code.gif new file mode 100644 index 00000000..912517b8 Binary files /dev/null and b/public/ckeditor/plugins/showprotected/images/code.gif differ diff --git a/public/ckeditor/plugins/showprotected/plugin.js b/public/ckeditor/plugins/showprotected/plugin.js new file mode 100644 index 00000000..a3e88132 --- /dev/null +++ b/public/ckeditor/plugins/showprotected/plugin.js @@ -0,0 +1,105 @@ +/* + * "showprotected" CKEditor plugin + * + * Created by Matthew Lieder (https://github.com/IGx89) + * + * Licensed under the MIT, GPL, LGPL and MPL licenses + * + * Icon courtesy of famfamfam: http://www.famfamfam.com/lab/icons/mini/ + */ + +// TODO: configuration settings +// TODO: show the actual text inline, not just an icon? +// TODO: improve copy/paste behavior (tooltip is wrong after paste) + +CKEDITOR.plugins.add( 'showprotected', { + requires: 'dialog,fakeobjects', + onLoad: function() { + // Add the CSS styles for protected source placeholders. + var iconPath = CKEDITOR.getUrl( this.path + 'images' + '/code.gif' ), + baseStyle = 'background:url(' + iconPath + ') no-repeat %1 center;border:1px dotted #00f;background-size:16px;'; + + var template = '.%2 img.cke_protected' + + '{' + + baseStyle + + 'width:16px;' + + 'min-height:15px;' + + // The default line-height on IE. + 'height:1.15em;' + + // Opera works better with "middle" (even if not perfect) + 'vertical-align:' + ( CKEDITOR.env.opera ? 'middle' : 'text-bottom' ) + ';' + + '}'; + + // Styles with contents direction awareness. + function cssWithDir( dir ) { + return template.replace( /%1/g, dir == 'rtl' ? 'right' : 'left' ).replace( /%2/g, 'cke_contents_' + dir ); + } + + CKEDITOR.addCss( cssWithDir( 'ltr' ) + cssWithDir( 'rtl' ) ); + }, + + init: function( editor ) { + CKEDITOR.dialog.add( 'showProtectedDialog', this.path + 'dialogs/protected.js' ); + + editor.on( 'doubleclick', function( evt ) { + var element = evt.data.element; + + if ( element.is( 'img' ) && element.hasClass( 'cke_protected' ) ) { + evt.data.dialog = 'showProtectedDialog'; + } + } ); + }, + + afterInit: function( editor ) { + // Register a filter to displaying placeholders after mode change. + + var dataProcessor = editor.dataProcessor, + dataFilter = dataProcessor && dataProcessor.dataFilter; + + if ( dataFilter ) { + dataFilter.addRules( { + comment: function( commentText, commentElement ) { + if(commentText.indexOf(CKEDITOR.plugins.showprotected.protectedSourceMarker) == 0) { + commentElement.attributes = []; + var fakeElement = editor.createFakeParserElement( commentElement, 'cke_protected', 'protected' ); + + var cleanedCommentText = CKEDITOR.plugins.showprotected.decodeProtectedSource( commentText ); + fakeElement.attributes.title = fakeElement.attributes.alt = cleanedCommentText; + + return fakeElement; + } + + return null; + } + } ); + } + } +} ); + +/** + * Set of showprotected plugin's helpers. + * + * @class + * @singleton + */ +CKEDITOR.plugins.showprotected = { + + protectedSourceMarker: '{cke_protected}', + + decodeProtectedSource: function( protectedSource ) { + if(protectedSource.indexOf('%3C!--') == 0) { + return decodeURIComponent(protectedSource).replace( //g, function( match, data ) { + return decodeURIComponent( data ); + } ); + } else { + return decodeURIComponent(protectedSource.substr(CKEDITOR.plugins.showprotected.protectedSourceMarker.length)); + } + }, + + encodeProtectedSource: function( protectedSource ) { + return ''; + } + +}; \ No newline at end of file diff --git a/src/Controller/LabelController.php b/src/Controller/LabelController.php index ae62408a..3d2444d6 100644 --- a/src/Controller/LabelController.php +++ b/src/Controller/LabelController.php @@ -26,6 +26,7 @@ use App\Entity\Contracts\NamedElementInterface; use App\Entity\LabelSystem\LabelOptions; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parts\Part; +use App\Exceptions\TwigModeException; use App\Form\LabelOptionsType; use App\Form\LabelSystem\LabelDialogType; use App\Helpers\LabelResponse; @@ -35,6 +36,7 @@ use App\Services\LabelSystem\LabelGenerator; use App\Services\Misc\RangeParser; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormError; use Symfony\Component\Form\SubmitButton; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Request; @@ -53,7 +55,8 @@ class LabelController extends AbstractController protected $elementTypeNameGenerator; protected $rangeParser; - public function __construct(LabelGenerator $labelGenerator, EntityManagerInterface $em, ElementTypeNameGenerator $elementTypeNameGenerator, RangeParser $rangeParser) + public function __construct(LabelGenerator $labelGenerator, EntityManagerInterface $em, ElementTypeNameGenerator $elementTypeNameGenerator, + RangeParser $rangeParser) { $this->labelGenerator = $labelGenerator; $this->em = $em; @@ -109,8 +112,13 @@ class LabelController extends AbstractController $target_id = (string) $form->get('target_id')->getData(); $targets = $this->findObjects($form_options->getSupportedElement(), $target_id); if (!empty($targets)) { - $pdf_data = $this->labelGenerator->generateLabel($form_options, $targets); - $filename = $this->getLabelName($targets[0], $profile); + try { + $pdf_data = $this->labelGenerator->generateLabel($form_options, $targets); + $filename = $this->getLabelName($targets[0], $profile); + } catch (TwigModeException $exception) { + $form->get('options')->get('lines')->addError(new FormError($exception->getMessage())); + } + } else { $this->addFlash('warning', 'label_generator.no_entities_found'); } diff --git a/src/Exceptions/TwigModeException.php b/src/Exceptions/TwigModeException.php new file mode 100644 index 00000000..67e54059 --- /dev/null +++ b/src/Exceptions/TwigModeException.php @@ -0,0 +1,33 @@ +. + */ + +namespace App\Exceptions; + + +use Throwable; +use Twig\Error\Error; + +class TwigModeException extends \RuntimeException +{ + public function __construct(Error $previous = null) + { + parent::__construct($previous->getMessage(), 0, $previous); + } +} \ No newline at end of file diff --git a/src/Form/LabelOptionsType.php b/src/Form/LabelOptionsType.php index 7b34eed0..408bfaee 100644 --- a/src/Form/LabelOptionsType.php +++ b/src/Form/LabelOptionsType.php @@ -105,6 +105,23 @@ class LabelOptionsType extends AbstractType ], 'required' => false, ]); + + $builder->add('lines_mode', ChoiceType::class, [ + 'label' => 'label_options.lines_mode.label', + 'choices' => [ + 'label_options.lines_mode.html' => 'html', + 'label.options.lines_mode.twig' => 'twig', + ], + 'help' => 'label_options.lines_mode.help', + 'help_html' => true, + 'expanded' => true, + 'attr' => [ + 'class' => 'pt-2' + ], + 'label_attr' => [ + 'class' => 'radio-custom radio-inline' + ] + ]); } public function configureOptions(OptionsResolver $resolver) diff --git a/src/Services/LabelSystem/LabelHTMLGenerator.php b/src/Services/LabelSystem/LabelHTMLGenerator.php index ed9aef94..9e89367b 100644 --- a/src/Services/LabelSystem/LabelHTMLGenerator.php +++ b/src/Services/LabelSystem/LabelHTMLGenerator.php @@ -22,24 +22,34 @@ namespace App\Services\LabelSystem; use App\Entity\Contracts\NamedElementInterface; use App\Entity\LabelSystem\LabelOptions; +use App\Exceptions\TwigModeException; use App\Services\ElementTypeNameGenerator; use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator; +use Symfony\Component\Security\Core\Security; use Twig\Environment; +use Twig\Error\Error; +use Twig\Error\SyntaxError; -class LabelHTMLGenerator +final class LabelHTMLGenerator { protected $twig; protected $elementTypeNameGenerator; protected $replacer; protected $barcodeGenerator; + protected $sandboxedTwigProvider; + protected $partdb_title; + protected $security; public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, LabelTextReplacer $replacer, Environment $twig, - BarcodeGenerator $barcodeGenerator) + BarcodeGenerator $barcodeGenerator, SandboxedTwigProvider $sandboxedTwigProvider, Security $security, string $partdb_title) { $this->twig = $twig; $this->elementTypeNameGenerator = $elementTypeNameGenerator; $this->replacer = $replacer; $this->barcodeGenerator = $barcodeGenerator; + $this->sandboxedTwigProvider = $sandboxedTwigProvider; + $this->security = $security; + $this->partdb_title = $partdb_title; } public function getLabelHTML(LabelOptions $options, array $elements): string @@ -49,15 +59,43 @@ class LabelHTMLGenerator } $twig_elements = []; + + if ($options->getLinesMode() === 'twig') { + $sandboxed_twig = $this->sandboxedTwigProvider->getTwig($options); + $current_user = $this->security->getUser(); + } + + $page = 1; foreach ($elements as $element) { + if ($options->getLinesMode() === 'twig' && $sandboxed_twig !== null) { + try { + $lines = $sandboxed_twig->render( + 'lines', + [ + 'element' => $element, + 'page' => $page, + 'user' => $current_user, + 'install_title' => $this->partdb_title, + ] + ); + } catch (Error $exception) { + throw new TwigModeException($exception); + } + } else { + $lines = $this->replacer->replace($options->getLines(), $element); + } + $twig_elements[] = [ 'element' => $element, - 'lines' => $this->replacer->replace($options->getLines(), $element), + 'lines' => $lines, 'barcode' => $this->barcodeGenerator->generateSVG($options, $element), 'barcode_content' => $this->barcodeGenerator->getContent($options, $element), ]; + + $page++; } + return $this->twig->render('LabelSystem/labels/base_label.html.twig', [ 'meta_title' => $this->getPDFTitle($options, $elements[0]), 'elements' => $twig_elements, @@ -65,6 +103,7 @@ class LabelHTMLGenerator ]); } + protected function getPDFTitle(LabelOptions $options, object $element) { if ($element instanceof NamedElementInterface) { diff --git a/src/Services/LabelSystem/SandboxedTwigProvider.php b/src/Services/LabelSystem/SandboxedTwigProvider.php new file mode 100644 index 00000000..86103ca9 --- /dev/null +++ b/src/Services/LabelSystem/SandboxedTwigProvider.php @@ -0,0 +1,140 @@ +. + */ + +namespace App\Services\LabelSystem; + + +use App\Entity\Attachments\Attachment; +use App\Entity\Attachments\AttachmentContainingDBElement; +use App\Entity\Base\AbstractCompany; +use App\Entity\Base\AbstractDBElement; +use App\Entity\Base\AbstractNamedDBElement; +use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\Contracts\NamedElementInterface; +use App\Entity\Contracts\TimeStampableInterface; +use App\Entity\LabelSystem\LabelOptions; +use App\Entity\Parameters\AbstractParameter; +use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\Part; +use App\Entity\Parts\PartLot; +use App\Entity\Parts\Storelocation; +use App\Entity\Parts\Supplier; +use App\Entity\PriceInformations\Currency; +use App\Entity\PriceInformations\Orderdetail; +use App\Entity\PriceInformations\Pricedetail; +use App\Entity\UserSystem\User; +use App\Twig\AppExtension; +use App\Twig\Sandbox\InheritanceSecurityPolicy; +use Twig\Environment; +use Twig\Extension\SandboxExtension; +use Twig\Extra\Intl\IntlExtension; +use Twig\Sandbox\SecurityPolicy; +use Twig\Sandbox\SecurityPolicyInterface; + +class SandboxedTwigProvider +{ + protected const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'verbatim', 'with']; + protected const ALLOWED_FILTERS = ['abs', 'batch', 'capitalize', 'column', 'country_name', + 'currency_name', 'currency_symbol', 'date', 'date_modify', 'default', 'escape', 'filter', 'first', 'format', + 'format_currency', 'format_date', 'format_datetime', 'format_number', 'format_time', 'join', 'keys', + 'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'merge', 'nl2br', 'raw', 'number_format', + 'reduce', 'replace', 'reverse', 'slice', 'sort', 'spaceless', 'split', 'striptags', 'timezone_name', 'title', + 'trim', 'upper', 'url_encode', + //Part-DB specific filters: + 'moneyFormat', 'siFormat', 'amountFormat']; + + protected const ALLOWED_FUNCTIONS = ['date', 'html_classes', 'max', 'min', 'random', 'range']; + + protected const ALLOWED_METHODS = [ + NamedElementInterface::class => ['getName'], + AbstractDBElement::class => ['getID', '__toString'], + TimeStampableInterface::class => ['getLastModified', 'getAddedDate'], + AbstractStructuralDBElement::class => ['isChildOf', 'isRoot', 'getParent', 'getComment', 'getLevel', + 'getFullPath', 'getPathArray', 'getChildren', 'isNotSelectable'], + AbstractCompany::class => ['getAddress', 'getPhoneNumber', 'getFaxNumber', 'getEmailAddress', 'getWebsite'], + AttachmentContainingDBElement::class => ['getAttachments', 'getMasterPictureAttachment'], + Attachment::class => ['isPicture', 'is3DModel', 'isExternal', 'isSecure', 'isBuiltIn', 'getExtension', + 'getElement', 'getURL', 'getFilename', 'getAttachmentType', 'getShowInTable'], + AbstractParameter::class => ['getFormattedValue', 'getGroup', 'getSymbol', 'getValueMin', 'getValueMax', + 'getValueTypical', 'getUnit', 'getValueText'], + MeasurementUnit::class => ['getUnit', 'isInteger', 'useSIPrefix'], + PartLot::class => ['isExpired', 'getDescription', 'getComment', 'getExpirationDate', 'getStorageLocation', + 'getPart', 'isInstockUnknown', 'getAmount', 'getNeedsRefill'], + Storelocation::class => ['isFull', 'isOnlySinglePart', 'isLimitToExistingParts', 'getStorageType'], + Supplier::class => ['getShippingCosts', 'getDefaultCurrency'], + Part::class => ['isNeedsReview', 'getTags', 'getMass', 'getDescription', 'isFavorite', 'getCategory', + 'getFootprint', 'getPartLots', 'getPartUnit', 'useFloatAmount', 'getMinAmount', 'getAmountSum', + 'getManufacturerProductUrl', 'getCustomProductURL', 'getManufacturingStatus', 'getManufacturer', + 'getManufacturerProductNumber', 'getOrderdetails', 'isObsolete'], + Currency::class => ['getIsoCode', 'getInverseExchangeRate', 'getExchangeRate'], + Orderdetail::class => ['getPart', 'getSupplier', 'getSupplierPartNr', 'getObsolete', + 'getPricedetails', 'findPriceForQty', ], + Pricedetail::class => ['getOrderdetail', 'getPrice', 'getPricePerUnit', 'getPriceRelatedQuantity', + 'getMinDiscountQuantity', 'getCurrency'], + //Only allow very little information about users... + User::class => ['isAnonymousUser', 'getUsername', 'getFullName', 'getFirstName', 'getLastName', + 'getDepartment', 'getEmail'] + + ]; + protected const ALLOWED_PROPERTIES = []; + + private $appExtension; + + public function __construct(AppExtension $appExtension) + { + $this->appExtension = $appExtension; + } + + public function getTwig(LabelOptions $options): Environment + { + if ($options->getLinesMode() !== 'twig') { + throw new \InvalidArgumentException('The LabelOptions must explicitly allow twig via lines_mode = "twig"!'); + } + + + $loader = new \Twig\Loader\ArrayLoader([ + 'lines' => $options->getLines(), + ]); + $twig = new Environment($loader); + + //Second argument activate sandbox globally. + $sandbox = new SandboxExtension($this->getSecurityPolicy(), true); + $twig->addExtension($sandbox); + + //Add IntlExtension + $twig->addExtension(new IntlExtension()); + + //Add Part-DB specific extension + $twig->addExtension($this->appExtension); + + return $twig; + } + + protected function getSecurityPolicy(): SecurityPolicyInterface + { + return new InheritanceSecurityPolicy( + self::ALLOWED_TAGS, + self::ALLOWED_FILTERS, + self::ALLOWED_METHODS, + self::ALLOWED_PROPERTIES, + self::ALLOWED_FUNCTIONS + ); + } +} \ No newline at end of file diff --git a/src/Twig/Sandbox/InheritanceSecurityPolicy.php b/src/Twig/Sandbox/InheritanceSecurityPolicy.php new file mode 100644 index 00000000..3f0a32d8 --- /dev/null +++ b/src/Twig/Sandbox/InheritanceSecurityPolicy.php @@ -0,0 +1,140 @@ + + */ +final class InheritanceSecurityPolicy implements SecurityPolicyInterface +{ + private $allowedTags; + private $allowedFilters; + private $allowedMethods; + private $allowedProperties; + private $allowedFunctions; + + public function __construct(array $allowedTags = [], array $allowedFilters = [], array $allowedMethods = [], array $allowedProperties = [], array $allowedFunctions = []) + { + $this->allowedTags = $allowedTags; + $this->allowedFilters = $allowedFilters; + $this->setAllowedMethods($allowedMethods); + $this->allowedProperties = $allowedProperties; + $this->allowedFunctions = $allowedFunctions; + } + + public function setAllowedTags(array $tags): void + { + $this->allowedTags = $tags; + } + + public function setAllowedFilters(array $filters): void + { + $this->allowedFilters = $filters; + } + + public function setAllowedMethods(array $methods): void + { + $this->allowedMethods = []; + foreach ($methods as $class => $m) { + $this->allowedMethods[$class] = array_map(function ($value) { return strtr($value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); }, \is_array($m) ? $m : [$m]); + } + } + + public function setAllowedProperties(array $properties): void + { + $this->allowedProperties = $properties; + } + + public function setAllowedFunctions(array $functions): void + { + $this->allowedFunctions = $functions; + } + + public function checkSecurity($tags, $filters, $functions): void + { + foreach ($tags as $tag) { + if (!\in_array($tag, $this->allowedTags)) { + throw new SecurityNotAllowedTagError(sprintf('Tag "%s" is not allowed.', $tag), $tag); + } + } + + foreach ($filters as $filter) { + if (!\in_array($filter, $this->allowedFilters)) { + throw new SecurityNotAllowedFilterError(sprintf('Filter "%s" is not allowed.', $filter), $filter); + } + } + + foreach ($functions as $function) { + if (!\in_array($function, $this->allowedFunctions)) { + throw new SecurityNotAllowedFunctionError(sprintf('Function "%s" is not allowed.', $function), $function); + } + } + } + + public function checkMethodAllowed($obj, $method): void + { + if ($obj instanceof Template || $obj instanceof Markup) { + return; + } + + $allowed = false; + $method = strtr($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + foreach ($this->allowedMethods as $class => $methods) { + if ($obj instanceof $class) { + $allowed = \in_array($method, $methods); + + //CHANGED: Only break if we the method is allowed, otherwise try it on the other methods + if ($allowed) { + break; + } + } + } + + if (!$allowed) { + $class = \get_class($obj); + throw new SecurityNotAllowedMethodError(sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method); + } + } + + public function checkPropertyAllowed($obj, $property): void + { + $allowed = false; + foreach ($this->allowedProperties as $class => $properties) { + if ($obj instanceof $class) { + $allowed = \in_array($property, \is_array($properties) ? $properties : [$properties]); + + //CHANGED: Only break if we the method is allowed, otherwise try it on the other methods + if ($allowed) { + break; + } + } + } + + if (!$allowed) { + $class = \get_class($obj); + throw new SecurityNotAllowedPropertyError(sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property); + } + } +}