1058 lines
42 KiB
PHP
1058 lines
42 KiB
PHP
<?php
|
|
/*
|
|
* Copyright (C) 2025 Xibo Signage Ltd
|
|
*
|
|
* Xibo - Digital Signage - https://xibosignage.com
|
|
*
|
|
* This file is part of Xibo.
|
|
*
|
|
* Xibo is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* any later version.
|
|
*
|
|
* Xibo is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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;
|
|
}
|
|
}
|