. */ namespace Xibo\Widget\Render; use Carbon\Carbon; use FilesystemIterator; use Illuminate\Support\Str; use Psr\Http\Message\RequestInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Slim\Views\Twig; use Twig\Extension\SandboxExtension; use Twig\Sandbox\SecurityPolicy; use Twig\TwigFilter; use Xibo\Entity\Display; use Xibo\Entity\Module; use Xibo\Entity\ModuleTemplate; use Xibo\Entity\Region; use Xibo\Entity\Widget; use Xibo\Factory\ModuleFactory; use Xibo\Helper\DateFormatHelper; use Xibo\Helper\LinkSigner; use Xibo\Helper\Translate; use Xibo\Service\ConfigServiceInterface; use Xibo\Support\Exception\InvalidArgumentException; use Xibo\Support\Sanitizer\SanitizerInterface; /** * Class responsible for rendering out a widgets HTML, caching it if necessary */ class WidgetHtmlRenderer { /** @var string Cache Path */ private $cachePath; /** @var LoggerInterface */ private $logger; /** @var \Slim\Views\Twig */ private $twig; /** @var \Xibo\Service\ConfigServiceInterface */ private $config; /** @var ModuleFactory */ private $moduleFactory; /** * @param string $cachePath * @param Twig $twig * @param ConfigServiceInterface $config * @param ModuleFactory $moduleFactory */ public function __construct( string $cachePath, Twig $twig, ConfigServiceInterface $config, ModuleFactory $moduleFactory ) { $this->cachePath = $cachePath; $this->twig = $twig; $this->config = $config; $this->moduleFactory = $moduleFactory; } /** * @param \Psr\Log\LoggerInterface $logger * @return $this */ public function useLogger(LoggerInterface $logger): WidgetHtmlRenderer { $this->logger = $logger; return $this; } private function getLog(): LoggerInterface { if ($this->logger === null) { $this->logger = new NullLogger(); } return $this->logger; } /** * @param \Xibo\Entity\Module $module * @param \Xibo\Entity\Region $region * @param \Xibo\Entity\Widget $widget * @param \Xibo\Support\Sanitizer\SanitizerInterface $params * @param string $downloadUrl * @param array $additionalContexts An array of additional key/value contexts for the templates * @return string * @throws \Twig\Error\LoaderError * @throws \Twig\Error\RuntimeError * @throws \Twig\Error\SyntaxError */ public function preview( Module $module, Region $region, Widget $widget, SanitizerInterface $params, string $downloadUrl, array $additionalContexts = [] ): string { if ($module->previewEnabled == 1) { $twigSandbox = $this->getTwigSandbox(); $width = $params->getDouble('width', ['default' => 0]); $height = $params->getDouble('height', ['default' => 0]); if ($module->preview !== null) { // Parse out our preview (which is always a stencil) $module->decorateProperties($widget, true); return $twigSandbox->fetchFromString( $module->preview->twig, array_merge( [ 'width' => $width, 'height' => $height, 'params' => $params, 'options' => $module->getPropertyValues(), 'downloadUrl' => $downloadUrl, 'calculatedDuration' => $widget->calculatedDuration, ], $module->getPropertyValues(), $additionalContexts ) ); } else if ($module->renderAs === 'html') { // Modules without a preview should render out as HTML return $this->twig->fetch( 'module-html-preview.twig', array_merge( [ 'width' => $width, 'height' => $height, 'regionId' => $region->regionId, 'widgetId' => $widget->widgetId, 'calculatedDuration' => $widget->calculatedDuration, ], $module->getPropertyValues(), $additionalContexts ) ); } } // Render an icon. return $this->twig->fetch('module-icon-preview.twig', [ 'moduleName' => $module->name, 'moduleType' => $module->type, 'moduleIcon' => $module->icon, ]); } /** * Render or cache. * ---------------- * @param ModuleTemplate[] $moduleTemplates * @param \Xibo\Entity\Widget[] $widgets * @throws \Twig\Error\SyntaxError * @throws \Twig\Error\RuntimeError * @throws \Twig\Error\LoaderError * @throws \Xibo\Support\Exception\NotFoundException */ public function renderOrCache( Region $region, array $widgets, array $moduleTemplates ): string { // HTML is cached per widget for regions of type zone/frame and playlist. // HTML is cached per region for regions of type canvas. $widgetModifiedDt = 0; if ($region->type === 'canvas') { foreach ($widgets as $item) { $widgetModifiedDt = max($widgetModifiedDt, $item->modifiedDt); if ($item->type === 'global') { $widget = $item; } } // If we don't have a global widget, just grab the first one. $widget = $widget ?? $widgets[0]; } else { $widget = $widgets[0]; $widgetModifiedDt = $widget->modifiedDt; } if (!file_exists($this->cachePath)) { mkdir($this->cachePath, 0777, true); } // Cache File // ---------- // Widgets may or may not appear in the same Region each time they are previewed due to them potentially // being contained in a Playlist. // Region width/height only changes in Draft state, so the FE is responsible for asserting the correct // width/height relating scaling params when the preview first loads. $cachePath = $this->cachePath . DIRECTORY_SEPARATOR . $widget->widgetId . '_' . $region->regionId . '.html'; // Changes to the Playlist should also invalidate Widget HTML caches try { $playlistModifiedDt = Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $region->getPlaylist([ 'loadPermissions' => false, 'loadWidgets' => false, 'loadTags' => false, 'loadActions' => false, ])->modifiedDt); } catch (\Exception) { $this->getLog()->error('renderOrCache: cannot find playlist modifiedDt, using now'); $playlistModifiedDt = Carbon::now(); } // Have we changed since we last cached this widget $modifiedDt = max(Carbon::createFromTimestamp($widgetModifiedDt), $playlistModifiedDt); $cachedDt = Carbon::createFromTimestamp(file_exists($cachePath) ? filemtime($cachePath) : 0); $this->getLog()->debug('renderOrCache: Cache details - modifiedDt: ' . $modifiedDt->format(DateFormatHelper::getSystemFormat()) . ', cachedDt: ' . $cachedDt->format(DateFormatHelper::getSystemFormat()) . ', cachePath: ' . $cachePath); if ($modifiedDt->greaterThan($cachedDt) || !file_get_contents($cachePath)) { $this->getLog()->debug('renderOrCache: We will need to regenerate'); // Are we worried about concurrent requests here? // these aren't providing any data anymore, so in theory it shouldn't be possible to // get locked up here // We don't clear cached media here, as that comes along with data. if (file_exists($cachePath)) { $this->getLog()->debug('renderOrCache: Deleting cache file ' . $cachePath . ' which already existed'); unlink($cachePath); } // Render $output = $this->render($widget->widgetId, $region, $widgets, $moduleTemplates); // Cache to the library file_put_contents($cachePath, $output); $this->getLog()->debug('renderOrCache: Generate complete'); return $output; } else { $this->getLog()->debug('renderOrCache: Serving from cache'); return file_get_contents($cachePath); } } /** * Decorate the HTML output for a preview * @param \Xibo\Entity\Region $region * @param string $output * @param callable $urlFor * @param \Psr\Http\Message\ServerRequestInterface $request * @return string */ public function decorateForPreview( Region $region, string $output, callable $urlFor, RequestInterface $request ): string { $matches = []; preg_match_all('/\[\[(.*?)\]\]/', $output, $matches); foreach ($matches[1] as $match) { if ($match === 'PlayerBundle') { $output = str_replace('[[PlayerBundle]]', $urlFor('layout.preview.bundle', []), $output); } else if ($match === 'FontBundle') { $output = str_replace('[[FontBundle]]', $urlFor('library.font.css', []), $output); } else if ($match === 'ViewPortWidth') { $output = str_replace('[[ViewPortWidth]]', $region->width, $output); } else if (Str::startsWith($match, 'dataUrl')) { $value = explode('=', $match); $output = str_replace( '[[' . $match . ']]', $urlFor('module.getData', ['regionId' => $region->regionId, 'id' => $value[1]]), $output ); } else if (Str::startsWith($match, 'data=')) { // Not needed as this CMS is always capable of providing separate data. $output = str_replace('"[[' . $match . ']]"', '[]', $output); } else if (Str::startsWith($match, 'mediaId') || Str::startsWith($match, 'libraryId')) { $value = explode('=', $match); $params = ['id' => $value[1]]; if (Str::startsWith($match, 'mediaId')) { $params['type'] = 'image'; } $output = str_replace( '[[' . $match . ']]', $urlFor('library.download', $params) . '?preview=1', $output ); } else if (Str::startsWith($match, 'assetId')) { $value = explode('=', $match); $output = str_replace( '[[' . $match . ']]', $urlFor('module.asset.download', ['assetId' => $value[1]]) . '?preview=1', $output ); } else if (Str::startsWith($match, 'assetAlias')) { $value = explode('=', $match); $output = str_replace( '[[' . $match . ']]', $urlFor('module.asset.download', ['assetId' => $value[1]]) . '?preview=1&isAlias=1', $output ); } } // Handle CSP in preview $html = new \DOMDocument(); $html->loadHTML($output, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_SCHEMA_CREATE); foreach ($html->getElementsByTagName('script') as $node) { // We add this requests cspNonce to every script tag if ($node instanceof \DOMElement) { $node->setAttribute('nonce', $request->getAttribute('cspNonce')); } } return $html->saveHTML(); } /** * Decorate the HTML output for a player * @param \Xibo\Entity\Display $display * @param string $output * @param array $storedAs A keyed array of library media this widget has access to * @param bool $isSupportsDataUrl * @param array $data A keyed array of data this widget has access to * @param \Xibo\Widget\Definition\Asset[] $assets A keyed array of assets this widget has access to * @return string * @throws \Xibo\Support\Exception\NotFoundException */ public function decorateForPlayer( Display $display, string $output, array $storedAs, bool $isSupportsDataUrl = true, array $data = [], array $assets = [] ): string { // Do we need to add a URL prefix to the requests? $auth = $display->isPwa() ? '&v=7&serverKey=' . $this->config->getSetting('SERVER_KEY') . '&hardwareKey=' . $display->license : null; $encryptionKey = $this->config->getApiKeyDetails()['encryptionKey']; $cdnUrl = $this->config->getSetting('CDN_URL'); $matches = []; preg_match_all('/\[\[(.*?)\]\]/', $output, $matches); foreach ($matches[1] as $match) { if ($match === 'PlayerBundle') { if ($display->isPwa()) { $url = LinkSigner::generateSignedLink( $display, $encryptionKey, $cdnUrl, 'P', 1, 'bundle.min.js', 'bundle', true, ); } else { $url = 'bundle.min.js'; } $output = str_replace( '[[PlayerBundle]]', $url, $output, ); } else if ($match === 'FontBundle') { if ($display->isPwa()) { $url = LinkSigner::generateSignedLink( $display, $encryptionKey, $cdnUrl, 'P', 1, 'fonts.css', 'fontCss', true, ); } else { $url = 'fonts.css'; } $output = str_replace( '[[FontBundle]]', $url, $output, ); } else if ($match === 'ViewPortWidth') { if ($display->isPwa()) { $output = str_replace( '[[ViewPortWidth]]', explode('x', ($display->resolution ?: 'x'))[0], $output, ); } } else if (Str::startsWith($match, 'dataUrl')) { $value = explode('=', $match); $output = str_replace( '[[' . $match . ']]', $isSupportsDataUrl ? ($display->isPwa() ? '/pwa/getData?widgetId=' . $value[1] . $auth : $value[1] . '.json') : 'null', $output, ); } else if (Str::startsWith($match, 'data=')) { $value = explode('=', $match); $output = str_replace( '"[[' . $match . ']]"', isset($data[$value[1]]) ? json_encode($data[$value[1]]) : 'null', $output, ); } else if (Str::startsWith($match, 'mediaId') || Str::startsWith($match, 'libraryId')) { $value = explode('=', $match); if (array_key_exists($value[1], $storedAs)) { if ($display->isPwa()) { $url = LinkSigner::generateSignedLink( $display, $encryptionKey, $cdnUrl, 'M', $value[1], $storedAs[$value[1]], null, true, ); } else { $url = $storedAs[$value[1]]; } $output = str_replace( '[[' . $match . ']]', $url, $output, ); } else { $output = str_replace( '[[' . $match . ']]', '', $output, ); } } else if (Str::startsWith($match, 'assetId')) { $value = explode('=', $match); if (array_key_exists($value[1], $assets)) { $asset = $assets[$value[1]]; if ($display->isPwa()) { $url = LinkSigner::generateSignedLink( $display, $encryptionKey, $cdnUrl, 'P', $asset->id, $asset->getFilename(), 'asset', true, ); } else { $url = $asset->getFilename(); } $output = str_replace( '[[' . $match . ']]', $url, $output, ); } else { $output = str_replace( '[[' . $match . ']]', '', $output, ); } } else if (Str::startsWith($match, 'assetAlias')) { $value = explode('=', $match); foreach ($assets as $asset) { if ($asset->alias === $value[1]) { if ($display->isPwa()) { $url = LinkSigner::generateSignedLink( $display, $encryptionKey, $cdnUrl, 'P', $asset->id, $asset->getFilename(), 'asset', true, ); } else { $url = $asset->getFilename(); } $output = str_replace( '[[' . $match . ']]', $url, $output, ); continue 2; } } $output = str_replace('[[' . $match . ']]', '', $output); } } return $output; } /** * Render out the widgets HTML * @param \Xibo\Entity\Widget[] $widgets * @param ModuleTemplate[] $moduleTemplates * @throws \Twig\Error\SyntaxError * @throws \Twig\Error\RuntimeError * @throws \Twig\Error\LoaderError * @throws \Xibo\Support\Exception\NotFoundException */ private function render( int $widgetId, Region $region, array $widgets, array $moduleTemplates ): string { // Build a Twig Sandbox $twigSandbox = $this->getTwigSandbox(); // Build up some data for twig $twig = []; $twig['widgetId'] = $widgetId; $twig['hbs'] = []; $twig['twig'] = []; $twig['style'] = []; $twig['assets'] = []; $twig['onRender'] = []; $twig['onParseData'] = []; $twig['onDataLoad'] = []; $twig['onElementParseData'] = []; $twig['onTemplateRender'] = []; $twig['onTemplateVisible'] = []; $twig['onInitialize'] = []; $twig['templateProperties'] = []; $twig['elements'] = []; $twig['width'] = $region->width; $twig['height'] = $region->height; $twig['cmsDateFormat'] = $this->config->getSetting('DATE_FORMAT'); $twig['locale'] = Translate::GetJSLocale(); // Output some data for each widget. $twig['data'] = []; // Max duration $duration = 0; $numItems = 0; // Grab any global elements in our templates $globalElements = []; foreach ($moduleTemplates as $moduleTemplate) { if ($moduleTemplate->type === 'element' && $moduleTemplate->dataType === 'global') { // Add global elements to an array of extendable elements $globalElements[$moduleTemplate->templateId] = $moduleTemplate; } } $this->getLog()->debug('render: there are ' . count($globalElements) . ' global elements'); // Extend any elements which need to be extended. foreach ($moduleTemplates as $moduleTemplate) { if ($moduleTemplate->type === 'element' && !empty($moduleTemplate->extends) && array_key_exists($moduleTemplate->extends->template, $globalElements) ) { $extends = $globalElements[$moduleTemplate->extends->template]; $this->getLog()->debug('render: extending template ' . $moduleTemplate->templateId); // Merge properties $moduleTemplate->properties = array_merge($extends->properties, $moduleTemplate->properties); // Store on the object to use when we output the stencil $moduleTemplate->setUnmatchedProperty('extends', $extends); } } // Render each widget out into the html foreach ($widgets as $widget) { $this->getLog()->debug('render: widget to process is widgetId: ' . $widget->widgetId); $this->getLog()->debug('render: ' . count($widgets) . ' widgets, ' . count($moduleTemplates) . ' templates'); // Get the module. $module = $this->moduleFactory->getByType($widget->type); // Decorate our module with the saved widget properties // we include the defaults. $module->decorateProperties($widget, true); // templateId or "elements" $templateId = $widget->getOptionValue('templateId', null); // Validate this modules properties. try { $module->validateProperties('status'); $widget->isValid = 1; } catch (InvalidArgumentException $invalidArgumentException) { $widget->isValid = 0; } // Parse out some common properties. $moduleLanguage = null; foreach ($module->properties as $property) { if ($property->type === 'languageSelector' && !empty($property->value)) { $moduleLanguage = $property->value; break; } } // Get an array of the modules property values. $modulePropertyValues = $module->getPropertyValues(); // Configure a translator for the module // Note: We are using the language defined against the module and not from the module template $translator = null; if ($moduleLanguage !== null) { $translator = Translate::getTranslationsFromLocale($moduleLanguage); } // Output some sample data and a data url. $widgetData = [ 'widgetId' => $widget->widgetId, 'templateId' => $templateId, 'sample' => $module->sampleData, 'properties' => $modulePropertyValues, 'isValid' => $widget->isValid === 1, 'isRepeatData' => $widget->getOptionValue('isRepeatData', 1) === 1, 'duration' => $widget->useDuration ? $widget->duration : $module->defaultDuration, 'calculatedDuration' => $widget->calculatedDuration, 'isDataExpected' => $module->isDataProviderExpected(), ]; // Should we expect data? if ($module->isDataProviderExpected()) { $widgetData['url'] = '[[dataUrl=' . $widget->widgetId . ']]'; $widgetData['data'] = '[[data=' . $widget->widgetId . ']]'; } else { $widgetData['url'] = null; $widgetData['data'] = null; } // Do we have a library file with this module? if ($module->regionSpecific == 0) { $widgetData['libraryId'] = '[[libraryId=' . $widget->getPrimaryMediaId() . ']]'; } // Output event functions for this widget if (!empty($module->onInitialize)) { $twig['onInitialize'][$widget->widgetId] = $module->onInitialize; } if (!empty($module->onParseData)) { $twig['onParseData'][$widget->widgetId] = $module->onParseData; } if (!empty($module->onDataLoad)) { $twig['onDataLoad'][$widget->widgetId] = $module->onDataLoad; } if (!empty($module->onRender)) { $twig['onRender'][$widget->widgetId] = $module->onRender; } if (!empty($module->onVisible)) { $twig['onVisible'][$widget->widgetId] = $module->onVisible; } // Include any module assets. foreach ($module->assets as $asset) { if ($asset->isSendToPlayer() && $asset->mimeType === 'text/css' || $asset->mimeType === 'text/javascript' ) { $twig['assets'][] = $asset; } } // Find my template if ($templateId !== 'elements') { // Render out the `twig` from our specific static template foreach ($moduleTemplates as $moduleTemplate) { if ($moduleTemplate->templateId === $templateId) { $moduleTemplate->decorateProperties($widget, true); $widgetData['templateProperties'] = $moduleTemplate->getPropertyValues(); $this->getLog()->debug('render: Static template to include: ' . $moduleTemplate->templateId); if ($moduleTemplate->stencil !== null) { if ($moduleTemplate->stencil->twig !== null) { $twig['twig'][] = $twigSandbox->fetchFromString( $this->decorateTranslations($moduleTemplate->stencil->twig, $translator), $widgetData['templateProperties'], ); } if ($moduleTemplate->stencil->style !== null) { $twig['style'][] = [ 'content' => $twigSandbox->fetchFromString( $moduleTemplate->stencil->style, $widgetData['templateProperties'], ), 'type' => $moduleTemplate->type, 'dataType' => $moduleTemplate->dataType, 'templateId' => $moduleTemplate->templateId, ]; } } break; } } } // Add to widgetData $twig['data'][] = $widgetData; // Watermark duration $duration = max($duration, $widget->calculatedDuration); // TODO: this won't always be right? can we make it right $numItems = max($numItems, $widgetData['properties']['numItems'] ?? 0); // What does our module have if ($module->stencil !== null) { // Stencils have access to any module properties if ($module->stencil->twig !== null) { $twig['twig'][] = $twigSandbox->fetchFromString( $this->decorateTranslations($module->stencil->twig, null), array_merge($modulePropertyValues, ['settings' => $module->getSettingsForOutput()]), ); } if ($module->stencil->hbs !== null) { $twig['hbs']['module'] = [ 'content' => $this->decorateTranslations($module->stencil->hbs, null), 'width' => $module->stencil->width, 'height' => $module->stencil->height, 'gapBetweenHbs' => $module->stencil->gapBetweenHbs, ]; } if ($module->stencil->head !== null) { $twig['head'][] = $twigSandbox->fetchFromString( $this->decorateTranslations($module->stencil->head, null), $modulePropertyValues, ); } if ($module->stencil->style !== null) { $twig['style'][] = [ 'content' => $twigSandbox->fetchFromString( $module->stencil->style, $modulePropertyValues, ), 'type' => $module->type, 'dataType' => $module->dataType, ]; } } // Include elements/element groups - they will already be JSON encoded. $widgetElements = $widget->getOptionValue('elements', null); if (!empty($widgetElements)) { $this->getLog()->debug('render: there are elements to include'); // Elements will be JSON $widgetElements = json_decode($widgetElements, true); // Are any of the module properties marked for sending to elements? $modulePropertiesToSend = []; if (count($widgetElements) > 0) { foreach ($module->properties as $property) { if ($property->sendToElements) { $modulePropertiesToSend[$property->id] = $modulePropertyValues[$property->id] ?? null; } } } // Join together the template properties for this element, and the element properties foreach ($widgetElements as $widgetIndex => $widgetElement) { // Assert the widgetId $widgetElements[$widgetIndex]['widgetId'] = $widget->widgetId; foreach (($widgetElement['elements'] ?? []) as $elementIndex => $element) { $this->getLog()->debug('render: elements: processing widget index ' . $widgetIndex . ', element index ' . $elementIndex . ' with id ' . $element['id']); foreach ($moduleTemplates as $moduleTemplate) { if ($moduleTemplate->templateId === $element['id']) { $this->getLog()->debug('render: elements: found template for element ' . $element['id']); // Merge the properties on the element with the properties on the template. $widgetElements[$widgetIndex]['elements'][$elementIndex]['properties'] = $moduleTemplate->getPropertyValues( true, $moduleTemplate->decoratePropertiesByArray( $element['properties'] ?? [], true ) ); // Update any properties which match on the element foreach ($modulePropertiesToSend as $propertyToSend => $valueToSend) { $widgetElements[$widgetIndex]['elements'] [$elementIndex]['properties'][$propertyToSend] = $valueToSend; } } } // Check the element for a mediaId property and set it to // [[mediaId=the_id_from_the_mediaId_property]] if (!empty($element['mediaId'])) { // Update the element so we output the mediaId replacement $widgetElements[$widgetIndex]['elements'][$elementIndex]['properties']['mediaId'] = '[[mediaId=' . $element['mediaId'] . ']]'; } } } $twig['elements'][] = json_encode($widgetElements); } } // Render out HBS/style from templates // we do not render Twig here foreach ($moduleTemplates as $moduleTemplate) { $this->getLog()->debug('render: outputting module template ' . $moduleTemplate->templateId); // Handle extends. $extension = $moduleTemplate->getUnmatchedProperty('extends'); $isExtensionHasHead = false; $isExtensionHasStyle = false; // Render out any hbs if ($moduleTemplate->stencil !== null && $moduleTemplate->stencil->hbs !== null) { // If we have an extension then look for %parent% and insert it. if ($extension !== null && Str::contains('%parent%', $moduleTemplate->stencil->hbs)) { $moduleTemplate->stencil->hbs = str_replace( '%parent%', $extension->stencil->hbs, $moduleTemplate->stencil->hbs ); } // Output the hbs $twig['hbs'][$moduleTemplate->templateId] = [ 'content' => $this->decorateTranslations($moduleTemplate->stencil->hbs, null), 'width' => $moduleTemplate->stencil->width, 'height' => $moduleTemplate->stencil->height, 'gapBetweenHbs' => $moduleTemplate->stencil->gapBetweenHbs, 'extends' => [ 'override' => $moduleTemplate->extends?->override, 'with' => $moduleTemplate->extends?->with, 'escapeHtml' => $moduleTemplate->extends?->escapeHtml ?? true, ], ]; } else if ($extension !== null) { // Output the extension HBS instead $twig['hbs'][$moduleTemplate->templateId] = [ 'content' => $this->decorateTranslations($extension->stencil->hbs, null), 'width' => $extension->stencil->width, 'height' => $extension->stencil->height, 'gapBetweenHbs' => $extension->stencil->gapBetweenHbs, 'extends' => [ 'override' => $moduleTemplate->extends?->override, 'with' => $moduleTemplate->extends?->with, 'escapeHtml' => $moduleTemplate->extends?->escapeHtml ?? true, ], ]; if ($extension->stencil->head !== null) { $twig['head'][] = $extension->stencil->head; $isExtensionHasHead = true; } if ($extension->stencil->style !== null) { $twig['style'][] = [ 'content' => $extension->stencil->style, 'type' => $moduleTemplate->type, 'dataType' => $moduleTemplate->dataType, 'templateId' => $moduleTemplate->templateId, ]; $isExtensionHasStyle = true; } } // Render the module template's head, if present and not already output by the extension if ($moduleTemplate->stencil !== null && $moduleTemplate->stencil->head !== null && !$isExtensionHasHead ) { $twig['head'][] = $moduleTemplate->stencil->head; } // Render the module template's style, if present and not already output by the extension if ($moduleTemplate->stencil !== null && $moduleTemplate->stencil->style !== null && !$isExtensionHasStyle && $moduleTemplate->type === 'element' ) { // Add more info to the element style // so we can use it to create CSS scope $twig['style'][] = [ 'content' => $moduleTemplate->stencil->style, 'type' => $moduleTemplate->type, 'dataType' => $moduleTemplate->dataType, 'templateId' => $moduleTemplate->templateId, ]; } if ($moduleTemplate->onTemplateRender !== null) { $twig['onTemplateRender'][$moduleTemplate->templateId] = $moduleTemplate->onTemplateRender; } if ($moduleTemplate->onTemplateVisible !== null) { $twig['onTemplateVisible'][$moduleTemplate->templateId] = $moduleTemplate->onTemplateVisible; } if ($moduleTemplate->onElementParseData !== null) { $twig['onElementParseData'][$moduleTemplate->templateId] = $moduleTemplate->onElementParseData; } // Include any module template assets. foreach ($moduleTemplate->assets as $asset) { if ($asset->isSendToPlayer() && $asset->mimeType === 'text/css' || $asset->mimeType === 'text/javascript' ) { $twig['assets'][] = $asset; } } } // Duration $twig['duration'] = $duration; $twig['numItems'] = $numItems; // We use the default get resource template. return $this->twig->fetch('widget-html-render.twig', $twig); } /** * Decorate translations in template files. * @param string $content * @param \GetText\Translator $translator * @return string */ private function decorateTranslations(string $content, ?\Gettext\Translator $translator): string { $matches = []; preg_match_all('/\|\|.*?\|\|/', $content, $matches); foreach ($matches[0] as $sub) { // Parse out the translateTag $translateTag = str_replace('||', '', $sub); // We have a valid translateTag to substitute if ($translator !== null) { $replace = $translator->gettext($translateTag); } else { $replace = __($translateTag); } // Substitute the replacement we have found (it might be '') $content = str_replace($sub, $replace, $content); } return $content; } /** * @param \Xibo\Entity\Widget $widget * @return void */ public function clearWidgetCache(Widget $widget) { $cachePath = $this->cachePath . DIRECTORY_SEPARATOR . $widget->widgetId . DIRECTORY_SEPARATOR; // Drop the cache // there is a chance this may not yet exist try { $it = new \RecursiveDirectoryIterator($cachePath, FilesystemIterator::SKIP_DOTS); $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); foreach ($files as $file) { if ($file->isDir()) { rmdir($file->getRealPath()); } else { unlink($file->getRealPath()); } } rmdir($cachePath); } catch (\UnexpectedValueException $unexpectedValueException) { $this->logger->debug('HTML cache doesn\'t exist yet or cannot be deleted. ' . $unexpectedValueException->getMessage()); } } /** * Get a Twig Sandbox * @return \Slim\Views\Twig * @throws \Twig\Error\LoaderError */ private function getTwigSandbox(): Twig { // Create a Twig Environment with a Sandbox $sandbox = Twig::create([ PROJECT_ROOT . '/modules', PROJECT_ROOT . '/custom', ], [ 'cache' => false, ]); // Add missing filter $sandbox->getEnvironment()->addFilter(new TwigFilter('url_decode', 'urldecode')); // Configure a security policy // Create a new security policy $policy = new SecurityPolicy(); // Allowed tags // import is allowed for weather static templates which import a macro $policy->setAllowedTags(['if', 'for', 'set', 'macro', 'import']); // Allowed filters $policy->setAllowedFilters(['escape', 'raw', 'url_decode']); // Create a Sandbox $sandbox->addExtension(new SandboxExtension($policy, true)); return $sandbox; } }