diff --git a/lib/Controller/Library.php b/lib/Controller/Library.php index 8eef10001f..a4c6c2206b 100644 --- a/lib/Controller/Library.php +++ b/lib/Controller/Library.php @@ -804,15 +804,32 @@ public function grid(Request $request, Response $response) * @throws GeneralException * @throws NotFoundException */ - public function search(Request $request, Response $response) + public function search(Request $request, Response $response): Response { $parsedQueryParams = $this->getSanitizer($request->getQueryParams()); $provider = $parsedQueryParams->getString('provider', ['default' => 'both']); $searchResults = new SearchResults(); if ($provider === 'both' || $provider === 'local') { - // Construct the SQL - $mediaList = $this->mediaFactory->query(['media.name'], $this->gridRenderFilter([ + // Sorting options. + // only allow from a preset list + $sortCol = match ($parsedQueryParams->getString('sortCol')) { + 'mediaId' => '`media`.`mediaId`', + 'orientation' => '`media`.`orientation`', + 'width' => '`media`.`width`', + 'height' => '`media`.`height`', + 'duration' => '`media`.`duration`', + 'fileSize' => '`media`.`fileSize`', + 'createdDt' => '`media`.`createdDt`', + 'modifiedDt' => '`media`.`modifiedDt`', + default => '`media`.`name`', + }; + $sortDir = match ($parsedQueryParams->getString('sortDir')) { + 'DESC' => ' DESC', + default => ' ASC' + }; + + $mediaList = $this->mediaFactory->query([$sortCol . $sortDir], $this->gridRenderFilter([ 'name' => $parsedQueryParams->getString('media'), 'useRegexForName' => $parsedQueryParams->getCheckbox('useRegexForName'), 'nameExact' => $parsedQueryParams->getString('nameExact'), diff --git a/lib/Controller/Widget.php b/lib/Controller/Widget.php index dadb4aeb4e..2c571e6208 100644 --- a/lib/Controller/Widget.php +++ b/lib/Controller/Widget.php @@ -1539,8 +1539,32 @@ public function saveElements(Request $request, Response $response, $id) // Store the target regionId $widget->load(); + // Pull out elements directly from the request body + $elements = $request->getBody()->getContents(); + $elementJson = json_decode($elements, true); + if ($elementJson === null) { + throw new InvalidArgumentException(__('Invalid element JSON'), 'body'); + } + + // Parse the element JSON to see if we need to set `itemsPerPage` + $slots = []; + $uniqueSlots = 0; + foreach ($elementJson as $widgetElement) { + foreach ($widgetElement['elements'] ?? [] as $element) { + $slotNo = 'slot_' . ($element['slot'] ?? 0); + if (!in_array($slotNo, $slots)) { + $slots[] = $slotNo; + $uniqueSlots++; + } + } + } + + if ($uniqueSlots > 0) { + $widget->setOptionValue('itemsPerPage', 'attrib', $uniqueSlots); + } + // Save elements - $widget->setOptionValue('elements', 'raw', $request->getBody()->getContents()); + $widget->setOptionValue('elements', 'raw', $elements); // Save $widget->save([ diff --git a/lib/Entity/DataSet.php b/lib/Entity/DataSet.php index 3314431ab1..6549529608 100644 --- a/lib/Entity/DataSet.php +++ b/lib/Entity/DataSet.php @@ -840,9 +840,8 @@ public function save($options = []) // Columns if ($options['saveColumns']) { foreach ($this->columns as $column) { - /* @var \Xibo\Entity\DataSetColumn $column */ $column->dataSetId = $this->dataSetId; - $column->save(); + $column->save($options); } } diff --git a/lib/Entity/DataSetColumn.php b/lib/Entity/DataSetColumn.php index 5ea26b1bdc..07ef620770 100644 --- a/lib/Entity/DataSetColumn.php +++ b/lib/Entity/DataSetColumn.php @@ -194,8 +194,13 @@ public function listContentArray() * Validate * @throws InvalidArgumentException */ - public function validate() + public function validate($options = []) { + $options = array_merge([ + 'testFormulas' => true, + 'allowSpacesInHeading' => false, + ], $options); + if ($this->dataSetId == 0 || $this->dataSetId == '') throw new InvalidArgumentException(__('Missing dataSetId'), 'dataSetId'); @@ -208,8 +213,16 @@ public function validate() if ($this->heading == '') throw new InvalidArgumentException(__('Please provide a column heading.'), 'heading'); - if (!v::stringType()->alnum()->validate($this->heading) || strtolower($this->heading) == 'id') - throw new InvalidArgumentException(__('Please provide an alternative column heading %s can not be used.', $this->heading), 'heading'); + // We allow spaces here for backwards compatibility, but only on import and edit. + $additionalCharacters = $options['allowSpacesInHeading'] ? ' ' : ''; + if (!v::stringType()->alnum($additionalCharacters)->validate($this->heading) + || strtolower($this->heading) == 'id' + ) { + throw new InvalidArgumentException(sprintf( + __('Please provide an alternative column heading %s can not be used.'), + $this->heading + ), 'heading'); + } if ($this->dataSetColumnTypeId == 2 && $this->formula == '') { throw new InvalidArgumentException(__('Please enter a valid formula'), 'formula'); @@ -273,33 +286,50 @@ public function validate() throw new InvalidArgumentException(__('New list content value is invalid as it does not include values for existing data'), 'listcontent'); } - // if formula dataSetType is set and formula is not empty, try to execute the SQL to validate it - we're ignoring client side formulas here. - if ($this->dataSetColumnTypeId == 2 && $this->formula != '' && substr($this->formula, 0, 1) !== '$') { - try { - $formula = str_replace('[DisplayId]', 0, $this->formula); - $this->getStore()->select('SELECT * FROM (SELECT `id`, ' . $formula . ' AS `' . $this->heading . '` FROM `dataset_' . $this->dataSetId . '`) dataset WHERE 1 = 1 ', []); - } catch (\Exception $e) { - $this->getLog()->debug('Formula validation failed with following message ' . $e->getMessage()); - throw new InvalidArgumentException(__('Provided formula is invalid'), 'formula'); - } + // if formula dataSetType is set and formula is not empty, try to execute the SQL to validate it - we're + // ignoring client side formulas here. + if ($options['testFormulas'] + && $this->dataSetColumnTypeId == 2 + && $this->formula != '' + && !str_starts_with($this->formula, '$') + ) { + try { + $formula = str_replace('[DisplayId]', 0, $this->formula); + $this->getStore()->select(' + SELECT * + FROM ( + SELECT `id`, ' . $formula . ' AS `' . $this->heading . '` + FROM `dataset_' . $this->dataSetId . '` + ) dataset + ', []); + } catch (\Exception $e) { + $this->getLog()->debug('Formula validation failed with following message ' . $e->getMessage()); + throw new InvalidArgumentException(__('Provided formula is invalid'), 'formula'); + } } } /** * Save - * @param array[Optional] $options + * @param array $options * @throws InvalidArgumentException */ public function save($options = []) { - $options = array_merge(['validate' => true, 'rebuilding' => false], $options); + $options = array_merge([ + 'validate' => true, + 'rebuilding' => false, + ], $options); - if ($options['validate'] && !$options['rebuilding']) - $this->validate(); - if ($this->dataSetColumnId == 0) + if ($options['validate'] && !$options['rebuilding']) { + $this->validate($options); + } + + if ($this->dataSetColumnId == 0) { $this->add(); - else + } else { $this->edit($options); + } } /** diff --git a/lib/Entity/Module.php b/lib/Entity/Module.php index a5f32cce4b..4dac4a35d7 100644 --- a/lib/Entity/Module.php +++ b/lib/Entity/Module.php @@ -354,13 +354,52 @@ public function createDataProvider(Widget $widget): DataProvider */ public function fetchDurationOrDefaultFromFile(string $file): int { - if ($this->widgetProvider === null) { - return $this->defaultDuration; + $this->getLog()->debug('fetchDurationOrDefaultFromFile: fetchDuration with file: ' . $file); + + // If we don't have a file name, then we use the default duration of 0 (end-detect) + if (empty($file)) { + return 0; + } else { + $info = new \getID3(); + $file = $info->analyze($file); + return intval($file['playtime_seconds'] ?? 0); } - $durationProvider = $this->moduleFactory->createDurationProvider($file, null); + } + + /** + * Calculate the duration of this Widget. + * @param Widget $widget + * @return int|null + */ + public function calculateDuration(Widget $widget): ?int + { + if ($this->widgetProvider === null && $this->regionSpecific === 1) { + // Take some default action to cover the majourity of region specific widgets + // Duration can depend on the number of items per page for some widgets + // this is a legacy way of working, and our preference is to use elements + $numItems = $widget->getOptionValue('numItems', 15); + + if ($widget->getOptionValue('durationIsPerItem', 0) == 1 && $numItems > 1) { + // If we have paging involved then work out the page count. + $itemsPerPage = $widget->getOptionValue('itemsPerPage', 0); + if ($itemsPerPage > 0) { + $numItems = ceil($numItems / $itemsPerPage); + } + + return $widget->calculatedDuration * $numItems; + } else { + return null; + } + } else if ($this->widgetProvider === null) { + return null; + } + + $this->getLog()->debug('calculateDuration: using widget provider'); + + $durationProvider = $this->moduleFactory->createDurationProvider($this, $widget); $this->widgetProvider->fetchDuration($durationProvider); - return $durationProvider->getDuration(); + return $durationProvider->isDurationSet() ? $durationProvider->getDuration() : null; } /** diff --git a/lib/Entity/Widget.php b/lib/Entity/Widget.php index 4ab683b457..4fefdcddfb 100644 --- a/lib/Entity/Widget.php +++ b/lib/Entity/Widget.php @@ -765,33 +765,13 @@ public function calculateDuration( $event = new SubPlaylistDurationEvent($this); $this->getDispatcher()->dispatch($event, SubPlaylistDurationEvent::$NAME); $this->calculatedDuration = $event->getDuration(); - } else if (($module->type === 'video' || $module->type === 'audio') && $this->useDuration === 0) { - // Video/Audio needs handling for the default duration being 0. - $this->getLog()->debug('calculateDuration: ' . $module->type . ' without specified duration'); - - try { - $mediaId = $this->getPrimaryMediaId(); - $this->calculatedDuration = $this->widgetMediaFactory->getDurationForMediaId($mediaId); - } catch (NotFoundException $notFoundException) { - $this->getLog()->error('calculateDuration: video/audio without primaryMediaId. widgetId: ' - . $this->widgetId); - } - } else if ($module->regionSpecific === 1) { - // Non-file based module - $this->getLog()->debug('calculateDuration: ' . $module->type . ', non-file based module.'); - - // Duration can depend on the number of items per page for some widgets - // this is a legacy way of working, and our preference is to use elements - $numItems = $this->getOptionValue('numItems', 15); - - if ($this->getOptionValue('durationIsPerItem', 0) == 1 && $numItems > 1) { - // If we have paging involved then work out the page count. - $itemsPerPage = $this->getOptionValue('itemsPerPage', 0); - if ($itemsPerPage > 0) { - $numItems = ceil($numItems / $itemsPerPage); - } - - $this->calculatedDuration = $this->calculatedDuration * $numItems; + } else { + // Our module will calculate the duration for us. + $duration = $module->calculateDuration($this); + if ($duration !== null) { + $this->calculatedDuration = $duration; + } else { + $this->getLog()->debug('calculateDuration: Duration not set by module'); } } @@ -799,6 +779,15 @@ public function calculateDuration( return $this; } + /** + * @return int + * @throws NotFoundException + */ + public function getDurationForMedia(): int + { + return $this->widgetMediaFactory->getDurationForMediaId($this->getPrimaryMediaId()); + } + /** * Load the Widget * @param bool $loadActions diff --git a/lib/Event/MenuBoardModifiedDtRequest.php b/lib/Event/MenuBoardModifiedDtRequest.php new file mode 100644 index 0000000000..c945517407 --- /dev/null +++ b/lib/Event/MenuBoardModifiedDtRequest.php @@ -0,0 +1,60 @@ +. + */ + +namespace Xibo\Event; + +use Carbon\Carbon; + +/** + * Menu Board Product Request. + */ +class MenuBoardModifiedDtRequest extends Event +{ + public static $NAME = 'menuboard.modifiedDt.request.event'; + + /** @var int */ + private $menuId; + + /** @var Carbon */ + private $modifiedDt; + + public function __construct(int $menuId) + { + $this->menuId = $menuId; + } + + public function getDataSetId(): int + { + return $this->menuId; + } + + public function setModifiedDt(Carbon $modifiedDt): MenuBoardModifiedDtRequest + { + $this->modifiedDt = $modifiedDt; + return $this; + } + + public function getModifiedDt(): ?Carbon + { + return $this->modifiedDt; + } +} diff --git a/lib/Factory/LayoutFactory.php b/lib/Factory/LayoutFactory.php index e385da6141..4c07e39da6 100644 --- a/lib/Factory/LayoutFactory.php +++ b/lib/Factory/LayoutFactory.php @@ -643,9 +643,8 @@ public function getLinkedFullScreenPlaylistId(int $campaignId): ?int * Load a layout by its XLF * @param string $layoutXlf * @param null $layout - * @return Layout - * @throws InvalidArgumentException - * @throws NotFoundException + * @return \Xibo\Entity\Layout + * @throws \Xibo\Support\Exception\GeneralException */ public function loadByXlf($layoutXlf, $layout = null) { @@ -656,9 +655,6 @@ public function loadByXlf($layoutXlf, $layout = null) $layout = $this->createEmpty(); } - // Get a list of modules for us to use - $modules = $this->moduleFactory->getKeyedArrayOfModules(); - // Parse the XML and fill in the details for this layout $document = new \DOMDocument(); if ($document->loadXML($layoutXlf) === false) { @@ -749,21 +745,6 @@ public function loadByXlf($layoutXlf, $layout = null) $this->setWidgetExpiryDatesOrDefault($widget); - $this->getLog()->debug('Adding Widget to object model. ' . $widget); - - // Does this module type exist? - if (!array_key_exists($widget->type, $modules)) { - $this->getLog()->error(sprintf( - 'Module Type [%s] in imported Layout does not exist. Allowable types: %s', - $widget->type, - json_encode(array_keys($modules)) - )); - continue; - } - - $module = $modules[$widget->type]; - /* @var \Xibo\Entity\Module $module */ - // // Get all widget options // @@ -785,7 +766,6 @@ public function loadByXlf($layoutXlf, $layout = null) && $widgetOption->value == '2' ) { $widget->type = 'datasetticker'; - $module = $modules[$widget->type]; } } } @@ -796,6 +776,15 @@ public function loadByXlf($layoutXlf, $layout = null) $xpathQuery )); + // Check legacy types from conditions, set widget type and upgrade + try { + $module = $this->prepareWidgetAndGetModule($widget); + } catch (NotFoundException) { + // Skip this widget + $this->getLog()->info('loadByJson: ' . $widget->type . ' could not be found or resolved'); + continue; + } + // // Get the MediaId associated with this widget (using the URI) // @@ -928,8 +917,6 @@ public function loadByJson($layoutJson, $playlistJson, $nestedPlaylistJson, Fold $oldIds = []; $newIds = []; $widgets = []; - // Get a list of modules for us to use - $modules = $this->moduleFactory->getKeyedArrayOfModules(); $layout->schemaVersion = (int)$layoutJson['layoutDefinitions']['schemaVersion']; $layout->width = $layoutJson['layoutDefinitions']['width']; @@ -1094,8 +1081,23 @@ public function loadByJson($layoutJson, $playlistJson, $nestedPlaylistJson, Fold $this->getLog()->debug('Adding Widget to object model. ' . $widget); - // Prepare widget options, check legacy types from conditions, set widget type and upgrade - $module = $this->prepareWidgetAndGetModule($widget, $mediaNode['widgetOptions']); + // Prepare widget options + foreach ($mediaNode['widgetOptions'] as $optionsNode) { + $widgetOption = $this->widgetOptionFactory->createEmpty(); + $widgetOption->type = $optionsNode['type']; + $widgetOption->option = $optionsNode['option']; + $widgetOption->value = $optionsNode['value']; + $widget->widgetOptions[] = $widgetOption; + } + + // Resolve the module + try { + $module = $this->prepareWidgetAndGetModule($widget); + } catch (NotFoundException) { + // Skip this widget + $this->getLog()->info('loadByJson: ' . $widget->type . ' could not be found or resolved'); + continue; + } // // Get the MediaId associated with this widget @@ -1445,7 +1447,7 @@ public function createFromZip( $this->getLog()->error('Skipping file on import due to invalid filename. ' . $fileName); continue; } - + $temporaryFileName = $libraryLocationTemp . $fileName; // Get the file from the ZIP @@ -1749,6 +1751,8 @@ public function createFromZip( $existingDataSet->save([ 'activate' => false, 'notify' => false, + 'testFormulas' => false, + 'allowSpacesInHeading' => true, ]); // Do we need to add data @@ -1759,7 +1763,7 @@ public function createFromZip( $existingDataSet->dataSetId )); - foreach ($item['data'] as $itemData) { + foreach (($item['data'] ?? []) as $itemData) { if (isset($itemData['id'])) { unset($itemData['id']); } @@ -1905,6 +1909,7 @@ public function createFromZip( // We need one final pass through all widgets on the layout so that we can set the durations properly. foreach ($layout->getAllWidgets() as $widget) { + // By now we should not have any modules which don't exist. $module = $this->moduleFactory->getByType($widget->type); $widget->calculateDuration($module, $importedFromXlf); @@ -1956,10 +1961,27 @@ public function createNestedPlaylistWidgets($widgets, $combined, &$playlists) $playlistWidget->tempId = $widgetsDetail['tempId']; $playlistWidget->mediaIds = $widgetsDetail['mediaIds']; $playlistWidget->widgetOptions = []; - $playlistWidget->schemaVersion = isset($widgetsDetail['schemaVersion']) ? (int)$widgetsDetail['schemaVersion'] : 1; + $playlistWidget->schemaVersion = isset($widgetsDetail['schemaVersion']) + ? (int)$widgetsDetail['schemaVersion'] + : 1; + + // Prepare widget options + foreach ($widgetsDetail['widgetOptions'] as $optionsNode) { + $widgetOption = $this->widgetOptionFactory->createEmpty(); + $widgetOption->type = $optionsNode['type']; + $widgetOption->option = $optionsNode['option']; + $widgetOption->value = $optionsNode['value']; + $playlistWidget->widgetOptions[] = $widgetOption; + } - // Prepare widget options, check legacy types from conditions, set widget type and upgrade - $module = $this->prepareWidgetAndGetModule($playlistWidget, $widgetsDetail['widgetOptions']); + try { + $module = $this->prepareWidgetAndGetModule($playlistWidget); + } catch (NotFoundException) { + // Skip this widget + $this->getLog()->info('createNestedPlaylistWidgets: ' . $playlistWidget->type + . ' could not be found or resolved'); + continue; + } if ($playlistWidget->type == 'subplaylist') { // Get the subplaylists from widget option @@ -2931,17 +2953,8 @@ public function convertOldPlaylistOptions($playlistIds, $playlistOptions) * Prepare widget options, check legacy types from conditions, set widget type and upgrade * @throws NotFoundException */ - private function prepareWidgetAndGetModule(Widget $widget, array $widgetOptions): Module + private function prepareWidgetAndGetModule(Widget $widget): Module { - // Get all widget options - foreach ($widgetOptions as $optionsNode) { - $widgetOption = $this->widgetOptionFactory->createEmpty(); - $widgetOption->type = $optionsNode['type']; - $widgetOption->option = $optionsNode['option']; - $widgetOption->value = $optionsNode['value']; - $widget->widgetOptions[] = $widgetOption; - } - // Form conditions from the widget's option and value, e.g, templateId==worldclock1 $widgetConditionMatch = []; foreach ($widget->widgetOptions as $option) { @@ -2955,7 +2968,8 @@ private function prepareWidgetAndGetModule(Widget $widget, array $widgetOptions) throw new NotFoundException(__('Module not found')); } - // Set the widget type + // Set the widget type and then assert the new one + $widget->setOriginalValue('type', $widget->type); $widget->type = $module->type; // Upgrade if necessary diff --git a/lib/Factory/ModuleFactory.php b/lib/Factory/ModuleFactory.php index f6284a89e0..0ab166583d 100644 --- a/lib/Factory/ModuleFactory.php +++ b/lib/Factory/ModuleFactory.php @@ -93,13 +93,13 @@ public function createDataProvider(Module $module, Widget $widget): DataProvider } /** - * @param string $path - * @param int|null $duration + * @param Module $module + * @param Widget $widget * @return DurationProviderInterface */ - public function createDurationProvider(string $path, ?int $duration): DurationProviderInterface + public function createDurationProvider(Module $module, Widget $widget): DurationProviderInterface { - return new DurationProvider($path, $duration); + return new DurationProvider($module, $widget); } /** diff --git a/lib/Listener/MenuBoardProviderListener.php b/lib/Listener/MenuBoardProviderListener.php index aed34045e7..3f88f024a0 100644 --- a/lib/Listener/MenuBoardProviderListener.php +++ b/lib/Listener/MenuBoardProviderListener.php @@ -22,10 +22,13 @@ namespace Xibo\Listener; +use Carbon\Carbon; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Xibo\Event\MenuBoardCategoryRequest; +use Xibo\Event\MenuBoardModifiedDtRequest; use Xibo\Event\MenuBoardProductRequest; use Xibo\Factory\MenuBoardCategoryFactory; +use Xibo\Factory\MenuBoardFactory; use Xibo\Support\Exception\NotFoundException; /** @@ -35,10 +38,13 @@ class MenuBoardProviderListener { use ListenerLoggerTrait; + private MenuBoardFactory $menuBoardFactory; + private MenuBoardCategoryFactory $menuBoardCategoryFactory; - public function __construct(MenuBoardCategoryFactory $menuBoardCategoryFactory) + public function __construct(MenuBoardFactory $menuBoardFactory, MenuBoardCategoryFactory $menuBoardCategoryFactory) { + $this->menuBoardFactory = $menuBoardFactory; $this->menuBoardCategoryFactory = $menuBoardCategoryFactory; } @@ -46,6 +52,7 @@ public function registerWithDispatcher(EventDispatcherInterface $dispatcher): Me { $dispatcher->addListener(MenuBoardProductRequest::$NAME, [$this, 'onProductRequest']); $dispatcher->addListener(MenuBoardCategoryRequest::$NAME, [$this, 'onCategoryRequest']); + $dispatcher->addListener(MenuBoardModifiedDtRequest::$NAME, [$this, 'onModifiedDtRequest']); return $this; } @@ -76,7 +83,7 @@ public function onProductRequest(MenuBoardProductRequest $event): void $categoryId = $dataProvider->getProperty('categoryId'); $this->getLogger()->debug('onProductRequest: $categoryId: ' . $categoryId); - if ($categoryId !== null && $categoryId !== "") { + if ($categoryId !== null && $categoryId !== '') { $filter['menuCategoryId'] = intval($categoryId); } @@ -151,4 +158,10 @@ public function onCategoryRequest(MenuBoardCategoryRequest $event): void $dataProvider->setIsHandled(); } + + public function onModifiedDtRequest(MenuBoardModifiedDtRequest $event): void + { + $menu = $this->menuBoardFactory->getById($event->getDataSetId()); + $event->setModifiedDt(Carbon::createFromTimestamp($menu->modifiedDt)); + } } diff --git a/lib/Middleware/CustomMiddlewareTrait.php b/lib/Middleware/CustomMiddlewareTrait.php index c6c8d49dc4..4dd9112b94 100644 --- a/lib/Middleware/CustomMiddlewareTrait.php +++ b/lib/Middleware/CustomMiddlewareTrait.php @@ -1,8 +1,8 @@ registerWithDispatcher($dispatcher); (new MenuBoardProviderListener( - $c->get('menuBoardCategoryFactory') + $c->get('menuBoardFactory'), + $c->get('menuBoardCategoryFactory'), )) ->useLogger($c->get('logger')) ->registerWithDispatcher($dispatcher); diff --git a/lib/Middleware/SAMLAuthentication.php b/lib/Middleware/SAMLAuthentication.php index e2d3ff1408..5165456536 100644 --- a/lib/Middleware/SAMLAuthentication.php +++ b/lib/Middleware/SAMLAuthentication.php @@ -1,8 +1,8 @@ getContainer()->logoutRoute = 'saml.logout'; // Route providing SAML metadata - $app->get('/saml/metadata', function (\Slim\Http\ServerRequest $request, \Slim\Http\Response $response) { + $app->get('/saml/metadata', function (Request $request, Response $response) { $settings = new Settings($this->getConfig()->samlSettings, true); $metadata = $settings->getSPMetadata(); $errors = $settings->validateMetadata($metadata); @@ -72,19 +71,19 @@ public function addRoutes() }); // SAML Login - $app->get('/saml/login', function (\Slim\Http\ServerRequest $request, \Slim\Http\Response $response) { + $app->get('/saml/login', function (Request $request, Response $response) { // Initiate SAML SSO $auth = new Auth($this->getConfig()->samlSettings); return $auth->login(); }); // SAML Logout - $app->get('/saml/logout', function (\Slim\Http\ServerRequest $request, \Slim\Http\Response $response) { + $app->get('/saml/logout', function (Request $request, Response $response) { return $this->samlLogout($request, $response); })->setName('saml.logout'); // SAML Assertion Consumer Endpoint - $app->post('/saml/acs', function (\Slim\Http\ServerRequest $request, \Slim\Http\Response $response) { + $app->post('/saml/acs', function (Request $request, Response $response) { // Log some interesting things $this->getLog()->debug('Arrived at the ACS route with own URL: ' . Utils::getSelfRoutedURLNoQuery()); @@ -278,30 +277,40 @@ public function addRoutes() ]); } - // Redirect back to the originally-requested url + // Redirect back to the originally-requested url, if provided + // it is not clear why basename is used here, it seems to be something to do with a logout loop $params = $request->getParams(); - $redirect = !isset($params['RelayState']) || (isset($params['RelayState']) && basename($params['RelayState']) == 'login') + $relayState = $params['RelayState'] ?? null; + $redirect = empty($relayState) || basename($relayState) === 'login' ? $this->getRouteParser()->urlFor('home') - : $params['RelayState']; + : $relayState; return $response->withRedirect($redirect); } }); // Single Logout Service - $app->get('/saml/sls', function (\Slim\Http\ServerRequest $request, \Slim\Http\Response $response) use ($app) { - + $app->map(['GET', 'POST'], '/saml/sls', function (Request $request, Response $response) use ($app) { + // Make request to IDP $auth = new Auth($app->getContainer()->get('configService')->samlSettings); - $auth->processSLO(false, null, false, function () use ($request) { - $this->completeLogoutFlow($this->getUser($_SESSION['userid'], $request->getAttribute('ip_address')), $this->getSession(), $this->getLog(), $request); - }); + try { + $auth->processSLO(false, null, false, function () use ($request) { + // Audit that the IDP has completed this request. + $this->getLog()->setIpAddress($request->getAttribute('ip_address')); + $this->getLog()->audit('User', 0, 'Idp SLO completed', [ + 'UserAgent' => $request->getHeader('User-Agent') + ]); + }); + } catch (\Exception $e) { + // Ignored - get with getErrors() + } $errors = $auth->getErrors(); if (empty($errors)) { return $response->withRedirect($this->getRouteParser()->urlFor('home')); } else { - throw new AccessDeniedException("SLO failed. " . implode(', ', $errors)); + throw new AccessDeniedException('SLO failed. ' . implode(', ', $errors)); } }); @@ -309,8 +318,8 @@ public function addRoutes() } /** - * @param \Psr\Http\Message\ServerRequestInterface $request - * @param \Psr\Http\Message\ResponseInterface $response + * @param \Slim\Http\ServerRequest $request + * @param \Slim\Http\Response $response * @return \Psr\Http\Message\ResponseInterface|\Slim\Http\Response * @throws \OneLogin\Saml2\Error */ @@ -322,6 +331,14 @@ public function samlLogout(Request $request, Response $response) && isset($samlSettings['workflow']['slo']) && $samlSettings['workflow']['slo'] == true ) { + // Complete our own logout flow + $this->completeLogoutFlow( + $this->getUser($_SESSION['userid'], $request->getAttribute('ip_address')), + $this->getSession(), + $this->getLog(), + $request + ); + // Initiate SAML SLO $auth = new Auth($samlSettings); return $response->withRedirect($auth->logout()); @@ -335,7 +352,7 @@ public function samlLogout(Request $request, Response $response) * @return Response * @throws \OneLogin\Saml2\Error */ - public function redirectToLogin(Request $request) + public function redirectToLogin(\Psr\Http\Message\ServerRequestInterface $request) { if ($this->isAjax($request)) { return $this->createResponse($request)->withJson(ApplicationState::asRequiresLogin()); @@ -347,7 +364,7 @@ public function redirectToLogin(Request $request) } /** @inheritDoc */ - public function getPublicRoutes(Request $request) + public function getPublicRoutes(\Psr\Http\Message\ServerRequestInterface $request) { return array_merge($request->getAttribute('publicRoutes', []), [ '/saml/metadata', @@ -367,8 +384,11 @@ public function shouldRedirectPublicRoute($route) } /** @inheritDoc */ - public function addToRequest(Request $request) + public function addToRequest(\Psr\Http\Message\ServerRequestInterface $request) { - return $request->withAttribute('excludedCsrfRoutes', array_merge($request->getAttribute('excludedCsrfRoutes', []), ['/saml/acs'])); + return $request->withAttribute( + 'excludedCsrfRoutes', + array_merge($request->getAttribute('excludedCsrfRoutes', []), ['/saml/acs', '/saml/sls']) + ); } } diff --git a/lib/Widget/AudioProvider.php b/lib/Widget/AudioProvider.php index 6693edfd83..37731f5d83 100644 --- a/lib/Widget/AudioProvider.php +++ b/lib/Widget/AudioProvider.php @@ -23,6 +23,7 @@ namespace Xibo\Widget; use Carbon\Carbon; +use Xibo\Support\Exception\NotFoundException; use Xibo\Widget\Provider\DataProviderInterface; use Xibo\Widget\Provider\DurationProviderInterface; use Xibo\Widget\Provider\WidgetProviderInterface; @@ -42,17 +43,14 @@ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderIn public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface { - $fileName = $durationProvider->getFile(); - - $this->getLog()->debug('AudioProvider: fetchDuration with file: ' . $fileName); - - // If we don't have a file name, then we use the default duration of 0 (end-detect) - if (empty($fileName)) { - $durationProvider->setDuration(0); - } else { - $info = new \getID3(); - $file = $info->analyze($fileName); - $durationProvider->setDuration(intval($file['playtime_seconds'] ?? 0)); + // If we have not been provided a specific duration, we should use the duration stored in the library + try { + if ($durationProvider->getWidget()->useDuration === 0) { + $durationProvider->setDuration($durationProvider->getWidget()->getDurationForMedia()); + } + } catch (NotFoundException) { + $this->getLog()->error('fetchDuration: video/audio without primaryMediaId. widgetId: ' + . $durationProvider->getWidget()->getId()); } return $this; } diff --git a/lib/Widget/Compatibility/DatasetWidgetCompatibility.php b/lib/Widget/Compatibility/DatasetWidgetCompatibility.php index 0470191fc9..3699de0562 100644 --- a/lib/Widget/Compatibility/DatasetWidgetCompatibility.php +++ b/lib/Widget/Compatibility/DatasetWidgetCompatibility.php @@ -39,65 +39,73 @@ public function upgradeWidget(Widget $widget, int $fromSchema, int $toSchema): b { $this->getLog()->debug('upgradeWidget: '. $widget->getId(). ' from: '. $fromSchema.' to: '.$toSchema); + // Track if we've been upgraded. $upgraded = false; - $newTemplateId = null; - $overrideTemplate = $widget->getOptionValue('overrideTemplate', 0); - $templateId = $widget->getOptionValue('templateId', ''); - - foreach ($widget->widgetOptions as $option) { - if ($option->option === 'templateId') { - if ($overrideTemplate == 0) { - switch ($templateId) { - case 'empty': - $newTemplateId = 'dataset_table_1'; - break; - - case 'light-green': - $newTemplateId = 'dataset_table_2'; - break; - - case 'simple-round': - $newTemplateId = 'dataset_table_3'; - break; - - case 'transparent-blue': - $newTemplateId = 'dataset_table_4'; - break; - - case 'orange-grey-striped': - $newTemplateId = 'dataset_table_5'; - break; - - case 'split-rows': - $newTemplateId = 'dataset_table_6'; - break; - - case 'dark-round': - $newTemplateId = 'dataset_table_7'; - break; - - case 'pill-colored': - $newTemplateId = 'dataset_table_8'; - break; - - default: - break; + + // Did we originally come from a data set ticker? + if ($widget->getOriginalValue('type') === 'datasetticker') { + $newTemplateId = 'dataset_custom_html'; + $widget->changeOption('css', 'styleSheet'); + } else { + $newTemplateId = null; + $overrideTemplate = $widget->getOptionValue('overrideTemplate', 0); + $templateId = $widget->getOptionValue('templateId', ''); + + foreach ($widget->widgetOptions as $option) { + if ($option->option === 'templateId') { + if ($overrideTemplate == 0) { + switch ($templateId) { + case 'empty': + $newTemplateId = 'dataset_table_1'; + break; + + case 'light-green': + $newTemplateId = 'dataset_table_2'; + break; + + case 'simple-round': + $newTemplateId = 'dataset_table_3'; + break; + + case 'transparent-blue': + $newTemplateId = 'dataset_table_4'; + break; + + case 'orange-grey-striped': + $newTemplateId = 'dataset_table_5'; + break; + + case 'split-rows': + $newTemplateId = 'dataset_table_6'; + break; + + case 'dark-round': + $newTemplateId = 'dataset_table_7'; + break; + + case 'pill-colored': + $newTemplateId = 'dataset_table_8'; + break; + + default: + break; + } + } else { + $newTemplateId = 'dataset_table_custom_html'; } - } else { - $newTemplateId = 'dataset_table_custom_html'; } + } - if (!empty($newTemplateId)) { - $widget->setOptionValue('templateId', 'attrib', $newTemplateId); - $upgraded = true; - } + // We have changed the format of columns to be an array in v4. + $columns = $widget->getOptionValue('columns', ''); + if (!empty($columns)) { + $widget->setOptionValue('columns', 'attrib', '[' . $columns . ']'); + $upgraded = true; } } - // We have changed the format of columns to be an array in v4. - $columns = $widget->getOptionValue('columns', ''); - if (!empty($columns)) { - $widget->setOptionValue('columns', 'attrib', '[' . $columns . ']'); + if (!empty($newTemplateId)) { + $widget->setOptionValue('templateId', 'attrib', $newTemplateId); $upgraded = true; } diff --git a/lib/Widget/DataSetProvider.php b/lib/Widget/DataSetProvider.php index d0dec66011..07ca2dd697 100644 --- a/lib/Widget/DataSetProvider.php +++ b/lib/Widget/DataSetProvider.php @@ -2,7 +2,7 @@ /* * Copyright (C) 2023 Xibo Signage Ltd * - * Xibo - Digital Signage - http://www.xibo.org.uk + * Xibo - Digital Signage - https://xibosignage.com * * This file is part of Xibo. * @@ -49,6 +49,12 @@ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderIn public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface { + $this->getLog()->debug('fetchDuration'); + + $lowerLimit = $durationProvider->getWidget()->getOptionValue('lowerLimit', 0); + $upperLimit = $durationProvider->getWidget()->getOptionValue('upperLimit', 15); + $durationProvider->setDuration(($upperLimit - $lowerLimit) + * $durationProvider->getWidget()->calculatedDuration); return $this; } diff --git a/lib/Widget/MastodonProvider.php b/lib/Widget/MastodonProvider.php index ed4047dd14..786044f1c7 100644 --- a/lib/Widget/MastodonProvider.php +++ b/lib/Widget/MastodonProvider.php @@ -26,7 +26,7 @@ use GuzzleHttp\Exception\RequestException; use Xibo\Widget\DataType\SocialMedia; use Xibo\Widget\Provider\DataProviderInterface; -use Xibo\Widget\Provider\DurationProviderInterface; +use Xibo\Widget\Provider\DurationProviderNumItemsTrait; use Xibo\Widget\Provider\WidgetProviderInterface; use Xibo\Widget\Provider\WidgetProviderTrait; @@ -36,6 +36,7 @@ class MastodonProvider implements WidgetProviderInterface { use WidgetProviderTrait; + use DurationProviderNumItemsTrait; public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface { @@ -146,11 +147,6 @@ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderIn return $this; } - public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface - { - return $this; - } - public function getDataCacheKey(DataProviderInterface $dataProvider): ?string { // No special cache key requirements. diff --git a/lib/Widget/MenuBoardProductProvider.php b/lib/Widget/MenuBoardProductProvider.php index 101ba296d7..d0788e1051 100644 --- a/lib/Widget/MenuBoardProductProvider.php +++ b/lib/Widget/MenuBoardProductProvider.php @@ -23,6 +23,7 @@ namespace Xibo\Widget; use Carbon\Carbon; +use Xibo\Event\MenuBoardModifiedDtRequest; use Xibo\Event\MenuBoardProductRequest; use Xibo\Widget\Provider\DataProviderInterface; use Xibo\Widget\Provider\DurationProviderInterface; @@ -45,6 +46,19 @@ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderIn public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface { + $this->getLog()->debug('fetchDuration'); + + $lowerLimit = $durationProvider->getWidget()->getOptionValue('lowerLimit', 0); + $upperLimit = $durationProvider->getWidget()->getOptionValue('upperLimit', 15); + $numItems = $upperLimit - $lowerLimit; + + $itemsPerPage = $durationProvider->getWidget()->getOptionValue('itemsPerPage', 0); + if ($itemsPerPage > 0) { + $numItems = ceil($numItems / $itemsPerPage); + } + + $durationProvider->setDuration(($numItems) + * $durationProvider->getWidget()->calculatedDuration); return $this; } @@ -55,6 +69,15 @@ public function getDataCacheKey(DataProviderInterface $dataProvider): ?string public function getDataModifiedDt(DataProviderInterface $dataProvider): ?Carbon { - return null; + $this->getLog()->debug('fetchData: MenuBoardProductProvider passing to modifiedDt request event'); + $menuId = $dataProvider->getProperty('menuId'); + if ($menuId !== null) { + // Raise an event to get the modifiedDt of this dataSet + $event = new MenuBoardModifiedDtRequest($menuId); + $this->getDispatcher()->dispatch($event, MenuBoardModifiedDtRequest::$NAME); + return max($event->getModifiedDt(), $dataProvider->getWidgetModifiedDt()); + } else { + return null; + } } } diff --git a/lib/Widget/NotificationProvider.php b/lib/Widget/NotificationProvider.php index f9e9d08ef6..ad38c45012 100644 --- a/lib/Widget/NotificationProvider.php +++ b/lib/Widget/NotificationProvider.php @@ -2,7 +2,7 @@ /* * Copyright (C) 2023 Xibo Signage Ltd * - * Xibo - Digital Signage - http://www.xibo.org.uk + * Xibo - Digital Signage - https://xibosignage.com * * This file is part of Xibo. * @@ -25,13 +25,14 @@ use Carbon\Carbon; use Xibo\Event\NotificationDataRequestEvent; use Xibo\Widget\Provider\DataProviderInterface; -use Xibo\Widget\Provider\DurationProviderInterface; +use Xibo\Widget\Provider\DurationProviderNumItemsTrait; use Xibo\Widget\Provider\WidgetProviderInterface; use Xibo\Widget\Provider\WidgetProviderTrait; class NotificationProvider implements WidgetProviderInterface { use WidgetProviderTrait; + use DurationProviderNumItemsTrait; public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface { @@ -43,11 +44,6 @@ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderIn return $this; } - public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface - { - return $this; - } - public function getDataCacheKey(DataProviderInterface $dataProvider): ?string { return $dataProvider->getWidgetId() . '_' . $dataProvider->getDisplayId(); diff --git a/lib/Widget/Provider/DurationProvider.php b/lib/Widget/Provider/DurationProvider.php index 21ce9b2b25..d8581475ee 100644 --- a/lib/Widget/Provider/DurationProvider.php +++ b/lib/Widget/Provider/DurationProvider.php @@ -22,13 +22,19 @@ namespace Xibo\Widget\Provider; +use Xibo\Entity\Module; +use Xibo\Entity\Widget; + /** * Xibo's default implementation of the Duration Provider */ class DurationProvider implements DurationProviderInterface { - /** @var string */ - private $file; + /** @var Module */ + private $module; + + /** @var Widget */ + private $widget; /** @var int Duration in seconds */ private $duration; @@ -38,21 +44,13 @@ class DurationProvider implements DurationProviderInterface /** * Constructor - * @param string|null $file - * @param int|null $duration - */ - public function __construct(string $file, ?int $duration) - { - $this->file = $file; - $this->duration = $duration; - } - - /** - * @inheritDoc + * @param Module $module + * @param Widget $widget */ - public function getFile(): string + public function __construct(Module $module, Widget $widget) { - return $this->file; + $this->module = $module; + $this->widget = $widget; } /** @@ -80,4 +78,20 @@ public function isDurationSet(): bool { return $this->isDurationSet; } + + /** + * @inheritDoc + */ + public function getModule(): Module + { + return $this->module; + } + + /** + * @inheritDoc + */ + public function getWidget(): Widget + { + return $this->widget; + } } diff --git a/lib/Widget/Provider/DurationProviderInterface.php b/lib/Widget/Provider/DurationProviderInterface.php index 15b0276cdb..c7d8c109e5 100644 --- a/lib/Widget/Provider/DurationProviderInterface.php +++ b/lib/Widget/Provider/DurationProviderInterface.php @@ -22,16 +22,25 @@ namespace Xibo\Widget\Provider; +use Xibo\Entity\Module; +use Xibo\Entity\Widget; + /** * A duration provider is used to return the duration for a Widget which has a media file */ interface DurationProviderInterface { /** - * Get the fully qualified path name of the file that needs its duration assessed - * @return string the fully qualified path to the file + * Get the Module + * @return Module + */ + public function getModule(): Module; + + /** + * Get the Widget + * @return Widget */ - public function getFile(): string; + public function getWidget(): Widget; /** * Get the duration diff --git a/lib/Widget/Provider/DurationProviderNumItemsTrait.php b/lib/Widget/Provider/DurationProviderNumItemsTrait.php new file mode 100644 index 0000000000..5840bc9c69 --- /dev/null +++ b/lib/Widget/Provider/DurationProviderNumItemsTrait.php @@ -0,0 +1,50 @@ +. + */ + +namespace Xibo\Widget\Provider; + +/** + * A trait providing the duration for widgets using numItems, durationIsPerItem and itemsPerPage + */ +trait DurationProviderNumItemsTrait +{ + public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface + { + $this->getLog()->debug('fetchDuration: DurationProviderNumItemsTrait'); + + // Take some default action to cover the majourity of region specific widgets + // Duration can depend on the number of items per page for some widgets + // this is a legacy way of working, and our preference is to use elements + $numItems = $durationProvider->getWidget()->getOptionValue('numItems', 15); + + if ($durationProvider->getWidget()->getOptionValue('durationIsPerItem', 0) == 1 && $numItems > 1) { + // If we have paging involved then work out the page count. + $itemsPerPage = $durationProvider->getWidget()->getOptionValue('itemsPerPage', 0); + if ($itemsPerPage > 0) { + $numItems = ceil($numItems / $itemsPerPage); + } + + $durationProvider->setDuration($durationProvider->getWidget()->calculatedDuration * $numItems); + } + return $this; + } +} diff --git a/lib/Widget/RssProvider.php b/lib/Widget/RssProvider.php index efee996ee6..313b372d7a 100644 --- a/lib/Widget/RssProvider.php +++ b/lib/Widget/RssProvider.php @@ -33,16 +33,17 @@ use Xibo\Support\Exception\InvalidArgumentException; use Xibo\Widget\DataType\Article; use Xibo\Widget\Provider\DataProviderInterface; -use Xibo\Widget\Provider\DurationProviderInterface; +use Xibo\Widget\Provider\DurationProviderNumItemsTrait; use Xibo\Widget\Provider\WidgetProviderInterface; use Xibo\Widget\Provider\WidgetProviderTrait; /** - * Downloads a RSS feed and returns Article data types + * Downloads an RSS feed and returns Article data types */ class RssProvider implements WidgetProviderInterface { use WidgetProviderTrait; + use DurationProviderNumItemsTrait; public function fetchData(DataProviderInterface $dataProvider): WidgetProviderInterface { @@ -225,11 +226,6 @@ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderIn return $this; } - public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface - { - return $this; - } - public function getDataCacheKey(DataProviderInterface $dataProvider): ?string { // No special cache key requirements. diff --git a/lib/Widget/VideoProvider.php b/lib/Widget/VideoProvider.php index 3a54339968..0708ee1d42 100644 --- a/lib/Widget/VideoProvider.php +++ b/lib/Widget/VideoProvider.php @@ -23,6 +23,7 @@ namespace Xibo\Widget; use Carbon\Carbon; +use Xibo\Support\Exception\NotFoundException; use Xibo\Widget\Provider\DataProviderInterface; use Xibo\Widget\Provider\DurationProviderInterface; use Xibo\Widget\Provider\WidgetProviderInterface; @@ -42,17 +43,14 @@ public function fetchData(DataProviderInterface $dataProvider): WidgetProviderIn public function fetchDuration(DurationProviderInterface $durationProvider): WidgetProviderInterface { - $fileName = $durationProvider->getFile(); - - $this->getLog()->debug('VideoProvider: fetchDuration with file: ' . $fileName); - - // If we don't have a file name, then we use the default duration of 0 (end-detect) - if (empty($fileName)) { - $durationProvider->setDuration(0); - } else { - $info = new \getID3(); - $file = $info->analyze($fileName); - $durationProvider->setDuration(intval($file['playtime_seconds'] ?? 0)); + // If we have not been provided a specific duration, we should use the duration stored in the library + try { + if ($durationProvider->getWidget()->useDuration === 0) { + $durationProvider->setDuration($durationProvider->getWidget()->getDurationForMedia()); + } + } catch (NotFoundException) { + $this->getLog()->error('fetchDuration: video/audio without primaryMediaId. widgetId: ' + . $durationProvider->getWidget()->getId()); } return $this; } diff --git a/modules/menuboard-product.xml b/modules/menuboard-product.xml index 879bd42621..29eb5560f7 100644 --- a/modules/menuboard-product.xml +++ b/modules/menuboard-product.xml @@ -83,7 +83,7 @@ Lower Row Limit - Optionally provide a Lower Row Limit (enter 0 for no limit). + Provide a Lower Row Limit. 0 @@ -96,8 +96,8 @@ Upper Row Limit - Optionally provide an Upper Row Limit (enter 0 for no limit). - 0 + Provide an Upper Row Limit. + 15 0 diff --git a/modules/templates/dataset-static.xml b/modules/templates/dataset-static.xml index e5f269b365..ce0384eb8c 100644 --- a/modules/templates/dataset-static.xml +++ b/modules/templates/dataset-static.xml @@ -2490,8 +2490,11 @@ $datasetTableContainer.dataSetRender(properties); dataset none + + Main Template + - Sample sheet + Optional Stylesheet Template diff --git a/ui/src/layout-editor/main.js b/ui/src/layout-editor/main.js index 974ae2e378..d27672a0ab 100644 --- a/ui/src/layout-editor/main.js +++ b/ui/src/layout-editor/main.js @@ -3526,7 +3526,7 @@ lD.handleMessage = function(event) { const messageFromSender = event.data; if (messageFromSender == 'viewerStoppedPlaying') { // Refresh designer - lDrefreshEditor({ + lD.refreshEditor({ reloadToolbar: false, reloadViewer: true, reloadPropertiesPanel: true,