. */ namespace Xibo\Controller; use Carbon\Carbon; use GuzzleHttp\Psr7\Stream; use Intervention\Image\ImageManagerStatic as Img; use Mimey\MimeTypes; use Parsedown; use Psr\Http\Message\ResponseInterface; use Slim\Http\Response as Response; use Slim\Http\ServerRequest as Request; use Stash\Interfaces\PoolInterface; use Stash\Item; use Xibo\Entity\Region; use Xibo\Entity\Session; use Xibo\Event\TemplateProviderImportEvent; use Xibo\Factory\CampaignFactory; use Xibo\Factory\DataSetFactory; use Xibo\Factory\DisplayGroupFactory; use Xibo\Factory\LayoutFactory; use Xibo\Factory\MediaFactory; use Xibo\Factory\ModuleFactory; use Xibo\Factory\PlaylistFactory; use Xibo\Factory\ResolutionFactory; use Xibo\Factory\TagFactory; use Xibo\Factory\UserFactory; use Xibo\Factory\UserGroupFactory; use Xibo\Factory\WidgetDataFactory; use Xibo\Factory\WidgetFactory; use Xibo\Helper\DateFormatHelper; use Xibo\Helper\Environment; use Xibo\Helper\LayoutUploadHandler; use Xibo\Helper\Profiler; use Xibo\Helper\SendFile; use Xibo\Helper\Status; use Xibo\Service\MediaService; use Xibo\Service\MediaServiceInterface; use Xibo\Support\Exception\AccessDeniedException; use Xibo\Support\Exception\GeneralException; use Xibo\Support\Exception\InvalidArgumentException; use Xibo\Support\Exception\NotFoundException; use Xibo\Widget\Render\WidgetDownloader; use Xibo\Widget\SubPlaylistItem; /** * Class Layout * @package Xibo\Controller * */ class Layout extends Base { /** * @var Session */ private $session; /** * @var UserFactory */ private $userFactory; /** * @var ResolutionFactory */ private $resolutionFactory; /** * @var LayoutFactory */ private $layoutFactory; /** * @var ModuleFactory */ private $moduleFactory; /** * @var UserGroupFactory */ private $userGroupFactory; /** * @var TagFactory */ private $tagFactory; /** * @var MediaFactory */ private $mediaFactory; /** @var DataSetFactory */ private $dataSetFactory; /** @var CampaignFactory */ private $campaignFactory; /** @var DisplayGroupFactory */ private $displayGroupFactory; /** @var PoolInterface */ private $pool; /** @var MediaServiceInterface */ private $mediaService; private WidgetFactory $widgetFactory; private PlaylistFactory $playlistFactory; /** * Set common dependencies. * @param Session $session * @param UserFactory $userFactory * @param ResolutionFactory $resolutionFactory * @param LayoutFactory $layoutFactory * @param ModuleFactory $moduleFactory * @param UserGroupFactory $userGroupFactory * @param TagFactory $tagFactory * @param MediaFactory $mediaFactory * @param DataSetFactory $dataSetFactory * @param CampaignFactory $campaignFactory * @param $displayGroupFactory */ public function __construct( $session, $userFactory, $resolutionFactory, $layoutFactory, $moduleFactory, $userGroupFactory, $tagFactory, $mediaFactory, $dataSetFactory, $campaignFactory, $displayGroupFactory, $pool, MediaServiceInterface $mediaService, WidgetFactory $widgetFactory, private readonly WidgetDataFactory $widgetDataFactory, PlaylistFactory $playlistFactory, ) { $this->session = $session; $this->userFactory = $userFactory; $this->resolutionFactory = $resolutionFactory; $this->layoutFactory = $layoutFactory; $this->moduleFactory = $moduleFactory; $this->userGroupFactory = $userGroupFactory; $this->tagFactory = $tagFactory; $this->mediaFactory = $mediaFactory; $this->dataSetFactory = $dataSetFactory; $this->campaignFactory = $campaignFactory; $this->displayGroupFactory = $displayGroupFactory; $this->pool = $pool; $this->mediaService = $mediaService; $this->widgetFactory = $widgetFactory; $this->playlistFactory = $playlistFactory; } /** * @return LayoutFactory */ public function getLayoutFactory() { return $this->layoutFactory; } /** * @return DataSetFactory */ public function getDataSetFactory() { return $this->dataSetFactory; } /** * Displays the Layout Page * @param Request $request * @param Response $response * @return \Psr\Http\Message\ResponseInterface|Response * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ function displayPage(Request $request, Response $response) { // Call to render the template $this->getState()->template = 'layout-page'; return $this->render($request, $response); } /** * Display the Layout Designer * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function displayDesigner(Request $request, Response $response, $id) { $layout = $this->layoutFactory->loadById($id); $sanitizedParams = $this->getSanitizer($request->getParams()); if (!$this->getUser()->checkEditable($layout)) throw new AccessDeniedException(); // Get the parent layout if it's editable if ($layout->isEditable()) { // Get the Layout using the Draft ID $layout = $this->layoutFactory->getByParentId($id); } // Work out our resolution, if it does not exist, create it. try { if ($layout->schemaVersion < 2) { $resolution = $this->resolutionFactory->getByDesignerDimensions($layout->width, $layout->height); } else { $resolution = $this->resolutionFactory->getByDimensions($layout->width, $layout->height); } } catch (NotFoundException $notFoundException) { $this->getLog()->info('Layout Editor with an unknown resolution, we will create it with name: ' . $layout->width . ' x ' . $layout->height); $resolution = $this->resolutionFactory->create($layout->width . ' x ' . $layout->height, (int)$layout->width, (int)$layout->height); $resolution->userId = $this->userFactory->getSystemUser()->userId; $resolution->save(); } $moduleFactory = $this->moduleFactory; $isTemplate = $layout->hasTag('template'); // Get a list of timezones $timeZones = []; foreach (DateFormatHelper::timezoneList() as $key => $value) { $timeZones[] = ['id' => $key, 'value' => $value]; } // Set up any JavaScript translations $data = [ 'publishedLayoutId' => $id, 'layout' => $layout, 'resolution' => $resolution, 'isTemplate' => $isTemplate, 'zoom' => $sanitizedParams->getDouble('zoom', [ 'default' => $this->getUser()->getOptionValue('defaultDesignerZoom', 1) ]), 'modules' => $moduleFactory->getAssignableModules(), 'timeZones' => $timeZones, ]; // Call the render the template $this->getState()->template = 'layout-designer-page'; $this->getState()->setData($data); return $this->render($request, $response); } /** * Add a Layout * @SWG\Post( * path="/layout", * operationId="layoutAdd", * tags={"layout"}, * summary="Add a Layout", * description="Add a new Layout to the CMS", * @SWG\Parameter( * name="name", * in="formData", * description="The layout name", * type="string", * required=false * ), * @SWG\Parameter( * name="description", * in="formData", * description="The layout description", * type="string", * required=false * ), * @SWG\Parameter( * name="layoutId", * in="formData", * description="If the Layout should be created with a Template, provide the ID, otherwise don't provide", * type="integer", * required=false * ), * @SWG\Parameter( * name="resolutionId", * in="formData", * description="If a Template is not provided, provide the resolutionId for this Layout.", * type="integer", * required=false * ), * @SWG\Parameter( * name="returnDraft", * in="formData", * description="Should we return the Draft Layout or the Published Layout on Success?", * type="boolean", * required=false * ), * @SWG\Parameter( * name="code", * in="formData", * description="Code identifier for this Layout", * type="string", * required=false * ), * @SWG\Parameter( * name="folderId", * in="formData", * description="Folder ID to which this object should be assigned to", * type="integer", * required=false * ), * @SWG\Response( * response=201, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout"), * @SWG\Header( * header="Location", * description="Location of the new record", * type="string" * ) * ) * ) * * @param Request $request * @param Response $response * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function add(Request $request, Response $response) { $sanitizedParams = $this->getSanitizer($request->getParams()); $name = $sanitizedParams->getString('name'); $description = $sanitizedParams->getString('description'); $enableStat = $sanitizedParams->getCheckbox('enableStat'); $autoApplyTransitions = (int)$this->getConfig()->getSetting('DEFAULT_TRANSITION_AUTO_APPLY'); $code = $sanitizedParams->getString('code', ['defaultOnEmptyString' => true]); // Folders $folderId = $sanitizedParams->getInt('folderId'); if ($folderId === 1) { $this->checkRootFolderAllowSave(); } if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) { $folderId = $this->getUser()->homeFolderId; } // Name if (empty($name)) { // Create our own name for this layout. $name = sprintf(__('Untitled %s'), Carbon::now()->format(DateFormatHelper::getSystemFormat())); } // Tags if ($this->getUser()->featureEnabled('tag.tagging')) { $tags = $this->tagFactory->tagsFromString($sanitizedParams->getString('tags')); } else { $tags = []; } $templateId = $sanitizedParams->getString('layoutId'); $resolutionId = $sanitizedParams->getInt('resolutionId'); $template = null; // If we have a templateId provided then we create from there. if (!empty($templateId)) { $this->getLog()->debug('add: loading template for clone operation. templateId: ' . $templateId); // Load the template $template = $this->layoutFactory->loadById($templateId); // Empty all the ID's $layout = clone $template; // Overwrite our new properties $layout->layout = $name; $layout->description = $description; $layout->code = $code; $layout->updateTagLinks($tags); $this->getLog()->debug('add: loaded and cloned, about to setOwner. templateId: ' . $templateId); // Set the owner $layout->setOwner($this->getUser()->userId, true); } else { $this->getLog()->debug('add: no template, using resolution: ' . $resolutionId); // Empty template so we create a blank layout with the provided resolution if (empty($resolutionId)) { // Get the nearest landscape resolution we can $resolution = $this->resolutionFactory->getClosestMatchingResolution(1920, 1080); // Get the ID $resolutionId = $resolution->resolutionId; $this->getLog()->debug('add: resolution resolved: ' . $resolutionId); } $layout = $this->layoutFactory->createFromResolution( $resolutionId, $this->getUser()->userId, $name, $description, $tags, $code, false ); } // Do we have an 'Enable Layout Stats Collection?' checkbox? // If not, we fall back to the default Stats Collection setting. if (!$sanitizedParams->hasParam('enableStat')) { $enableStat = (int)$this->getConfig()->getSetting('LAYOUT_STATS_ENABLED_DEFAULT'); } // Set layout enableStat flag $layout->enableStat = $enableStat; // Set auto apply transitions flag $layout->autoApplyTransitions = $autoApplyTransitions; // set folderId $layout->folderId = $folderId; // Save $layout->save(['appendCountOnDuplicate' => true]); if ($templateId != null && $template !== null) { $layout->copyActions($layout, $template); // set Layout original values to current values $layout->setOriginals(); } $allRegions = array_merge($layout->regions, $layout->drawers); foreach ($allRegions as $region) { /* @var Region $region */ if ($templateId != null && $template !== null) { // Match our original region id to the id in the parent layout $original = $template->getRegionOrDrawer($region->getOriginalValue('regionId')); // Make sure Playlist closure table from the published one are copied over $original->getPlaylist()->cloneClosureTable($region->getPlaylist()->playlistId); // set Region original values to current values $region->setOriginals(); foreach ($region->regionPlaylist->widgets as $widget) { // set Widget original values to current values $widget->setOriginals(); } } $campaign = $this->campaignFactory->getById($layout->campaignId); $playlist = $region->getPlaylist(); $playlist->folderId = $campaign->folderId; $playlist->permissionsFolderId = $campaign->permissionsFolderId; $playlist->save(); } $this->getLog()->debug('Layout Added'); // Automatically checkout the new layout for edit $layout = $this->layoutFactory->checkoutLayout($layout, $sanitizedParams->getCheckbox('returnDraft')); // Return $this->getState()->hydrate([ 'httpStatus' => 201, 'message' => sprintf(__('Added %s'), $layout->layout), 'id' => $layout->layoutId, 'data' => $layout ]); return $this->render($request, $response); } /** * Edit Layout * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented * @SWG\Put( * path="/layout/{layoutId}", * operationId="layoutEdit", * summary="Edit Layout", * description="Edit a Layout", * tags={"layout"}, * @SWG\Parameter( * name="layoutId", * type="integer", * in="path", * required=true * ), * @SWG\Parameter( * name="name", * in="formData", * description="The Layout Name", * type="string", * required=true * ), * @SWG\Parameter( * name="description", * in="formData", * description="The Layout Description", * type="string", * required=false * ), * @SWG\Parameter( * name="tags", * in="formData", * description="A comma separated list of Tags", * type="string", * required=false * ), * @SWG\Parameter( * name="retired", * in="formData", * description="A flag indicating whether this Layout is retired.", * type="integer", * required=false * ), * @SWG\Parameter( * name="enableStat", * in="formData", * description="Flag indicating whether the Layout stat is enabled", * type="integer", * required=false * ), * @SWG\Parameter( * name="code", * in="formData", * description="Code identifier for this Layout", * type="string", * required=false * ), * @SWG\Parameter( * name="folderId", * in="formData", * description="Folder ID to which this object should be assigned to", * type="integer", * required=false * ), * @SWG\Response( * response=200, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout") * ) * ) */ public function edit(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); $sanitizedParams = $this->getSanitizer($request->getParams()); $folderChanged = false; $nameChanged = false; // check if we're dealing with the template $isTemplate = $layout->hasTag('template'); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) throw new AccessDeniedException(); // Make sure we're not a draft if ($layout->isChild()) { throw new InvalidArgumentException(__('Cannot edit Layout properties on a Draft'), 'layoutId'); } $layout->layout = $sanitizedParams->getString('name'); $layout->description = $sanitizedParams->getString('description'); if ($this->getUser()->featureEnabled('tag.tagging')) { $layout->updateTagLinks($this->tagFactory->tagsFromString($sanitizedParams->getString('tags'))); } // if it was not a template, and user added template tag, throw an error. if (!$isTemplate && $layout->hasTag('template')) { throw new InvalidArgumentException(__('Cannot assign a Template tag to a Layout, to create a template use the Save Template button instead.'), 'tags'); } $layout->retired = $sanitizedParams->getCheckbox('retired'); $layout->enableStat = $sanitizedParams->getCheckbox('enableStat'); $layout->code = $sanitizedParams->getString('code'); $layout->folderId = $sanitizedParams->getInt('folderId', ['default' => $layout->folderId]); if ($layout->hasPropertyChanged('folderId')) { if ($layout->folderId === 1) { $this->checkRootFolderAllowSave(); } $folderChanged = true; } if ($layout->hasPropertyChanged('layout')) { $nameChanged = true; } // Save $layout->save([ 'saveLayout' => true, 'saveRegions' => false, 'saveTags' => true, 'setBuildRequired' => false, 'notify' => false ]); if ($folderChanged || $nameChanged) { // permissionsFolderId depends on the Campaign, hence why we need to get the edited Layout back here $editedLayout = $this->layoutFactory->getById($layout->layoutId); // this will return the original Layout we edited and its draft $layouts = $this->layoutFactory->getByCampaignId($layout->campaignId, true, true); foreach ($layouts as $savedLayout) { // if we changed the name of the original Layout, updated its draft name as well if ($savedLayout->isChild() && $nameChanged) { $savedLayout->layout = $editedLayout->layout; $savedLayout->save([ 'saveLayout' => true, 'saveRegions' => false, 'saveTags' => false, 'setBuildRequired' => false, 'notify' => false ]); } // if the folder changed on original Layout, make sure we keep its regionPlaylists and draft regionPlaylists updated if ($folderChanged) { $savedLayout->load(); $allRegions = array_merge($savedLayout->regions, $savedLayout->drawers); foreach ($allRegions as $region) { $playlist = $region->getPlaylist(); $playlist->folderId = $editedLayout->folderId; $playlist->permissionsFolderId = $editedLayout->permissionsFolderId; $playlist->save(); } } } } // Return $this->getState()->hydrate([ 'message' => sprintf(__('Edited %s'), $layout->layout), 'id' => $layout->layoutId, 'data' => $layout ]); return $this->render($request, $response); } /** * Edit Layout Background * @param Request $request * @param Response $response * @param $id * @return \Slim\Http\Response * @throws \Xibo\Support\Exception\GeneralException * @SWG\Put( * path="/layout/background/{layoutId}", * operationId="layoutEditBackground", * summary="Edit Layout Background", * description="Edit a Layout Background", * tags={"layout"}, * @SWG\Parameter( * name="layoutId", * type="integer", * in="path", * required=true * ), * @SWG\Parameter( * name="backgroundColor", * in="formData", * description="A HEX color to use as the background color of this Layout.", * type="string", * required=true * ), * @SWG\Parameter( * name="backgroundImageId", * in="formData", * description="A media ID to use as the background image for this Layout.", * type="integer", * required=false * ), * @SWG\Parameter( * name="backgroundzIndex", * in="formData", * description="The Layer Number to use for the background.", * type="integer", * required=true * ), * @SWG\Parameter( * name="resolutionId", * in="formData", * description="The Resolution ID to use on this Layout.", * type="integer", * required=false * ), * @SWG\Response( * response=200, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout") * ) * ) */ public function editBackground(Request $request, Response $response, $id): Response { $layout = $this->layoutFactory->getById($id); $sanitizedParams = $this->getSanitizer($request->getParams()); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(); } // Check that this Layout is a Draft if (!$layout->isChild()) { throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId'); } $layout->backgroundColor = $sanitizedParams->getString('backgroundColor'); $layout->backgroundImageId = $sanitizedParams->getInt('backgroundImageId'); $layout->backgroundzIndex = $sanitizedParams->getInt('backgroundzIndex'); $layout->autoApplyTransitions = $sanitizedParams->getCheckbox('autoApplyTransitions'); // Check the status of the media file if ($layout->backgroundImageId) { $media = $this->mediaFactory->getById($layout->backgroundImageId); if ($media->mediaType === 'image' && $media->released === 2) { throw new InvalidArgumentException(sprintf( __('%s set as the layout background image is too large. Please ensure that none of the images in your layout are larger than your Resize Limit on their longest edge.'),//phpcs:ignore $media->name )); } } // Resolution $saveRegions = false; $resolution = $this->resolutionFactory->getById($sanitizedParams->getInt('resolutionId')); if ($layout->width != $resolution->width || $layout->height != $resolution->height) { $this->getLog()->debug('editBackground: resolution dimensions have changed, updating layout'); $layout->load([ 'loadPlaylists' => false, 'loadPermissions' => false, 'loadCampaigns' => false, 'loadActions' => false, ]); $layout->width = $resolution->width; $layout->height = $resolution->height; $layout->orientation = ($layout->width >= $layout->height) ? 'landscape' : 'portrait'; // Update the canvas region with its new width/height. foreach ($layout->regions as $region) { if ($region->type === 'canvas') { $this->getLog()->debug('editBackground: canvas region needs changing too'); $region->width = $layout->width; $region->height = $layout->height; $saveRegions = true; } } } // Save $layout->save([ 'saveLayout' => true, 'saveRegions' => $saveRegions, 'saveTags' => true, 'setBuildRequired' => true, 'notify' => false ]); // Return $this->getState()->hydrate([ 'message' => sprintf(__('Edited %s'), $layout->layout), 'id' => $layout->layoutId, 'data' => $layout ]); return $this->render($request, $response); } /** * Apply a template to a Layout * @SWG\Put( * path="/layout/applyTemplate/{layoutId}", * operationId="layoutApplyTemplate", * tags={"layout"}, * summary="Apply Template", * description="Apply a new Template to an existing Layout, replacing it.", * @SWG\Parameter( * name="layoutId", * type="integer", * in="path", * required=true * ), * @SWG\Parameter( * name="templateId", * in="formData", * description="If the Layout should be created with a Template, provide the ID, otherwise don't provide", * type="integer", * required=false * ), * @SWG\Response( * response=204, * description="successful operation" * ) * ) * * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws \Xibo\Support\Exception\GeneralException */ public function applyTemplate(Request $request, Response $response, $id): Response { $sanitizedParams = $this->getSanitizer($request->getParams()); // Get the existing layout $layout = $this->layoutFactory->getById($id); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(); } // Check that this Layout is a Draft if (!$layout->isChild()) { throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId'); } // Discard the current draft and replace it $layout->discardDraft(false); // Is the source remote (undocumented as it should only be via WEB) $source = $sanitizedParams->getString('source'); if ($source === 'remote') { // Hand off to the connector $event = new TemplateProviderImportEvent( $sanitizedParams->getString('download'), $sanitizedParams->getString('templateId'), $this->getConfig()->getSetting('LIBRARY_LOCATION') ); $this->getLog()->debug('Dispatching event. ' . $event->getName()); try { $this->getDispatcher()->dispatch($event, $event->getName()); } catch (\Exception $exception) { $this->getLog()->error('Template search: Exception in dispatched event: ' . $exception->getMessage()); $this->getLog()->debug($exception->getTraceAsString()); } $template = $this->getLayoutFactory()->createFromZip( $event->getFilePath(), $layout->layout, $this->getUser()->userId, 0, 0, 0, 0, 1, $this->getDataSetFactory(), '', $this->mediaService, $layout->folderId, false, ); $template->managePlaylistClosureTable(); $template->manageActions(); // Handle widget data $fallback = $layout->getUnmatchedProperty('fallback'); if ($fallback !== null) { foreach ($layout->getAllWidgets() as $widget) { // Did this widget have fallback data included in its export? if (array_key_exists($widget->tempWidgetId, $fallback)) { foreach ($fallback[$widget->tempWidgetId] as $item) { // We create the widget data with the new widgetId $this->widgetDataFactory ->create( $widget->widgetId, $item['data'] ?? [], intval($item['displayOrder'] ?? 1), ) ->save(); } } } } @unlink($event->getFilePath()); } else { $templateId = $sanitizedParams->getInt('templateId'); $this->getLog()->debug('add: loading template for clone operation. templateId: ' . $templateId); // Clone the template $template = clone $this->layoutFactory->loadById($templateId); // Overwrite our new properties $template->layout = $layout->layout; $template->setOwner($layout->ownerId); } // Persist the parentId $template->parentId = $layout->parentId; $template->campaignId = $layout->campaignId; $template->publishedStatusId = 2; $template->save(['validate' => false]); // for remote source, we import the Layout and save the thumbnail to temporary file // after save we can move the image to correct library folder, as we have campaignId if ($source === 'remote' && !empty($layout->getUnmatchedProperty('thumbnail'))) { rename($layout->getUnmatchedProperty('thumbnail'), $template->getThumbnailUri()); } // Return $this->getState()->hydrate([ 'message' => sprintf(__('Edited %s'), $layout->layout), 'id' => $template->layoutId, 'data' => $template, ]); return $this->render($request, $response); } /** * Delete Layout Form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ function deleteForm(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); if (!$this->getUser()->checkDeleteable($layout)) throw new AccessDeniedException(__('You do not have permissions to delete this layout')); $data = [ 'layout' => $layout, ]; $this->getState()->template = 'layout-form-delete'; $this->getState()->setData($data); return $this->render($request, $response); } /** * Clear Layout Form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ function clearForm(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); if (!$this->getUser()->checkDeleteable($layout)) throw new AccessDeniedException(__('You do not have permissions to clear this layout')); $data = [ 'layout' => $layout, ]; $this->getState()->template = 'layout-form-clear'; $this->getState()->setData($data); return $this->render($request, $response); } /** * Retire Layout Form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function retireForm(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } $data = [ 'layout' => $layout, ]; $this->getState()->template = 'layout-form-retire'; $this->getState()->setData($data); return $this->render($request, $response); } /** * Deletes a layout * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented * @SWG\Delete( * path="/layout/{layoutId}", * operationId="layoutDelete", * tags={"layout"}, * summary="Delete Layout", * description="Delete a Layout", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout ID to Delete", * type="integer", * required=true * ), * @SWG\Response( * response=204, * description="successful operation" * ) * ) */ function delete(Request $request, Response $response, $id) { $layout = $this->layoutFactory->loadById($id); if (!$this->getUser()->checkDeleteable($layout)) { throw new AccessDeniedException(__('You do not have permissions to delete this layout')); } // Make sure we're not a draft if ($layout->isChild()) { throw new InvalidArgumentException(__('Cannot delete Layout from its Draft, delete the parent'), 'layoutId'); } $layout->delete(); // Return $this->getState()->hydrate([ 'httpStatus' => 204, 'message' => sprintf(__('Deleted %s'), $layout->layout) ]); return $this->render($request, $response); } /** * Clears a layout * @param Request $request * @param Response $response * @param $id * @return \Slim\Http\Response * @throws \Xibo\Support\Exception\GeneralException * * @SWG\Post( * path="/layout/{layoutId}", * operationId="layoutClear", * tags={"layout"}, * summary="Clear Layout", * description="Clear a draft layouts canvas of all widgets and elements, leaving it blank.", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout ID to Clear, must be a draft.", * type="integer", * required=true * ), * @SWG\Response( * response=201, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout"), * @SWG\Header( * header="Location", * description="Location of the new record", * type="string" * ) * ) * ) */ public function clear(Request $request, Response $response, $id): Response { // Get the existing layout $layout = $this->layoutFactory->getById($id); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(); } // Check that this Layout is a Draft if (!$layout->isChild()) { throw new InvalidArgumentException(__('This Layout is not a Draft, please checkout.'), 'layoutId'); } // Discard the current draft and replace it $layout->discardDraft(false); // Blank $resolution = $this->resolutionFactory->getClosestMatchingResolution($layout->width, $layout->height); $blank = $this->layoutFactory->createFromResolution( $resolution->resolutionId, $layout->ownerId, $layout->layout, null, null, null, false ); // Persist the parentId $blank->parentId = $layout->parentId; $blank->campaignId = $layout->campaignId; $blank->publishedStatusId = 2; $blank->save(['validate' => false, 'auditMessage' => 'Canvas Cleared']); // Return $this->getState()->hydrate([ 'message' => sprintf(__('Cleared %s'), $layout->layout), 'id' => $blank->layoutId, 'data' => $blank, ]); return $this->render($request, $response); } /** * Retires a layout * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented * @SWG\Put( * path="/layout/retire/{layoutId}", * operationId="layoutRetire", * tags={"layout"}, * summary="Retire Layout", * description="Retire a Layout so that it isn't available to Schedule. Existing Layouts will still be played", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout ID", * type="integer", * required=true * ), * @SWG\Response( * response=204, * description="successful operation" * ) * ) */ function retire(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } // Make sure we're not a draft if ($layout->isChild()) { throw new InvalidArgumentException(__('Cannot modify Layout from its Draft'), 'layoutId'); } // Make sure we aren't the global default if ($layout->layoutId == $this->getConfig()->getSetting('DEFAULT_LAYOUT')) { throw new InvalidArgumentException(__('This Layout is used as the global default and cannot be retired'), 'layoutId'); } $layout->retired = 1; $layout->save([ 'saveLayout' => true, 'saveRegions' => false, 'saveTags' => false, 'setBuildRequired' => false ]); // Return $this->getState()->hydrate([ 'httpStatus' => 204, 'message' => sprintf(__('Retired %s'), $layout->layout) ]); return $this->render($request, $response); } /** * Unretire Layout Form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function unretireForm(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } $data = [ 'layout' => $layout, ]; $this->getState()->template = 'layout-form-unretire'; $this->getState()->setData($data); return $this->render($request, $response); } /** * Unretires a layout * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented * @SWG\Put( * path="/layout/unretire/{layoutId}", * operationId="layoutUnretire", * tags={"layout"}, * summary="Unretire Layout", * description="Retire a Layout so that it isn't available to Schedule. Existing Layouts will still be played", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout ID", * type="integer", * required=true * ), * @SWG\Response( * response=204, * description="successful operation" * ) * ) */ function unretire(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } // Make sure we're not a draft if ($layout->isChild()) { throw new InvalidArgumentException(__('Cannot modify Layout from its Draft'), 'layoutId'); } $layout->retired = 0; $layout->save([ 'saveLayout' => true, 'saveRegions' => false, 'saveTags' => false, 'setBuildRequired' => false, ]); // Return $this->getState()->hydrate([ 'httpStatus' => 204, 'message' => sprintf(__('Unretired %s'), $layout->layout) ]); return $this->render($request, $response); } /** * Set Enable Stats Collection of a layout * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented * @SWG\Put( * path="/layout/setenablestat/{layoutId}", * operationId="layoutSetEnableStat", * tags={"layout"}, * summary="Enable Stats Collection", * description="Set Enable Stats Collection? to use for the collection of Proof of Play statistics for a Layout.", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout ID", * type="integer", * required=true * ), * @SWG\Parameter( * name="enableStat", * in="formData", * description="Flag indicating whether the Layout stat is enabled", * type="integer", * required=true * ), * @SWG\Response( * response=204, * description="successful operation" * ) * ) */ function setEnableStat(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); $sanitizedParams = $this->getSanitizer($request->getParams()); if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } // Make sure we're not a draft if ($layout->isChild()) { throw new InvalidArgumentException(__('Cannot modify Layout from its Draft'), 'layoutId'); } $enableStat = $sanitizedParams->getCheckbox('enableStat'); $layout->enableStat = $enableStat; $layout->save(['saveTags' => false]); // Return $this->getState()->hydrate([ 'httpStatus' => 204, 'message' => sprintf(__('For Layout %s Enable Stats Collection is set to %s'), $layout->layout, ($layout->enableStat == 1) ? __('On') : __('Off')) ]); return $this->render($request, $response); } /** * Set Enable Stat Form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function setEnableStatForm(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } $data = [ 'layout' => $layout, ]; $this->getState()->template = 'layout-form-setenablestat'; $this->getState()->setData($data); return $this->render($request, $response); } /** * Shows the Layout Grid * * @SWG\Get( * path="/layout", * operationId="layoutSearch", * tags={"layout"}, * summary="Search Layouts", * description="Search for Layouts viewable by this user", * @SWG\Parameter( * name="layoutId", * in="query", * description="Filter by Layout Id", * type="integer", * required=false * ), * @SWG\Parameter( * name="parentId", * in="query", * description="Filter by parent Id", * type="integer", * required=false * ), * @SWG\Parameter( * name="showDrafts", * in="query", * description="Flag indicating whether to show drafts", * type="integer", * required=false * ), * @SWG\Parameter( * name="layout", * in="query", * description="Filter by partial Layout name", * type="string", * required=false * ), * @SWG\Parameter( * name="userId", * in="query", * description="Filter by user Id", * type="integer", * required=false * ), * @SWG\Parameter( * name="retired", * in="query", * description="Filter by retired flag", * type="integer", * required=false * ), * @SWG\Parameter( * name="tags", * in="query", * description="Filter by Tags", * type="string", * required=false * ), * @SWG\Parameter( * name="exactTags", * in="query", * description="A flag indicating whether to treat the tags filter as an exact match", * type="integer", * required=false * ), * @SWG\Parameter( * name="logicalOperator", * in="query", * description="When filtering by multiple Tags, which logical operator should be used? AND|OR", * type="string", * required=false * ), * @SWG\Parameter( * name="ownerUserGroupId", * in="query", * description="Filter by users in this UserGroupId", * type="integer", * required=false * ), * @SWG\Parameter( * name="publishedStatusId", * in="query", * description="Filter by published status id, 1 - Published, 2 - Draft", * type="integer", * required=false * ), * @SWG\Parameter( * name="embed", * in="query", * description="Embed related data such as regions, playlists, widgets, tags, campaigns, permissions", * type="string", * required=false * ), * @SWG\Parameter( * name="campaignId", * in="query", * description="Get all Layouts for a given campaignId", * type="integer", * required=false * ), * @SWG\Parameter( * name="folderId", * in="query", * description="Filter by Folder ID", * type="integer", * required=false * ), * @SWG\Response( * response=200, * description="successful operation", * @SWG\Schema( * type="array", * @SWG\Items(ref="#/definitions/Layout") * ) * ) * ) * * @param Request $request * @param Response $response * @return \Psr\Http\Message\ResponseInterface|Response * @throws GeneralException * @throws NotFoundException * @throws \Twig\Error\LoaderError * @throws \Twig\Error\RuntimeError * @throws \Twig\Error\SyntaxError * @throws \Xibo\Support\Exception\ConfigurationException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function grid(Request $request, Response $response) { $this->getState()->template = 'grid'; $parsedQueryParams = $this->getSanitizer($request->getQueryParams()); // Should we parse the description into markdown $showDescriptionId = $parsedQueryParams->getInt('showDescriptionId'); // We might need to embed some extra content into the response if the "Show Description" // is set to media listing if ($showDescriptionId === 3) { $embed = ['regions', 'playlists', 'widgets']; } else { // Embed? $embed = ($parsedQueryParams->getString('embed') != null) ? explode(',', $parsedQueryParams->getString('embed')) : []; } // Get all layouts $layouts = $this->layoutFactory->query($this->gridRenderSort($parsedQueryParams), $this->gridRenderFilter([ 'layout' => $parsedQueryParams->getString('layout'), 'useRegexForName' => $parsedQueryParams->getCheckbox('useRegexForName'), 'userId' => $parsedQueryParams->getInt('userId'), 'retired' => $parsedQueryParams->getInt('retired'), 'tags' => $parsedQueryParams->getString('tags'), 'exactTags' => $parsedQueryParams->getCheckbox('exactTags'), 'filterLayoutStatusId' => $parsedQueryParams->getInt('layoutStatusId'), 'layoutId' => $parsedQueryParams->getInt('layoutId'), 'parentId' => $parsedQueryParams->getInt('parentId'), 'showDrafts' => $parsedQueryParams->getInt('showDrafts'), 'ownerUserGroupId' => $parsedQueryParams->getInt('ownerUserGroupId'), 'mediaLike' => $parsedQueryParams->getString('mediaLike'), 'publishedStatusId' => $parsedQueryParams->getInt('publishedStatusId'), 'activeDisplayGroupId' => $parsedQueryParams->getInt('activeDisplayGroupId'), 'campaignId' => $parsedQueryParams->getInt('campaignId'), 'folderId' => $parsedQueryParams->getInt('folderId'), 'codeLike' => $parsedQueryParams->getString('codeLike'), 'orientation' => $parsedQueryParams->getString('orientation', ['defaultOnEmptyString' => true]), 'onlyMyLayouts' => $parsedQueryParams->getCheckbox('onlyMyLayouts'), 'logicalOperator' => $parsedQueryParams->getString('logicalOperator'), 'logicalOperatorName' => $parsedQueryParams->getString('logicalOperatorName'), 'campaignType' => 'list', 'modifiedSinceDt' => $parsedQueryParams->getDate('modifiedSinceDt'), ], $parsedQueryParams)); foreach ($layouts as $layout) { /* @var \Xibo\Entity\Layout $layout */ if (in_array('regions', $embed)) { $layout->load([ 'loadPlaylists' => in_array('playlists', $embed), 'loadCampaigns' => in_array('campaigns', $embed), 'loadPermissions' => in_array('permissions', $embed), 'loadTags' => in_array('tags', $embed), 'loadWidgets' => in_array('widgets', $embed), 'loadActions' => in_array('actions', $embed) ]); } // Populate the status message $layout->getStatusMessage(); // Add Locking information $layout = $this->layoutFactory->decorateLockedProperties($layout); // Annotate each Widget with its validity, tags and permissions if (in_array('widget_validity', $embed) || in_array('tags', $embed) || in_array('permissions', $embed)) { foreach ($layout->getAllWidgets() as $widget) { try { $module = $this->moduleFactory->getByType($widget->type); } catch (NotFoundException $notFoundException) { // This module isn't available, mark it as invalid. $widget->isValid = false; $widget->setUnmatchedProperty('moduleName', __('Invalid Module')); $widget->setUnmatchedProperty('name', __('Invalid Module')); $widget->setUnmatchedProperty('tags', []); $widget->setUnmatchedProperty('isDeletable', 1); continue; } $widget->setUnmatchedProperty('moduleName', $module->name); $widget->setUnmatchedProperty('moduleDataType', $module->dataType); if ($module->regionSpecific == 0) { // Use the media assigned to this widget $media = $this->mediaFactory->getById($widget->getPrimaryMediaId()); $widget->setUnmatchedProperty('name', $widget->getOptionValue('name', null) ?: $media->name); // Augment with tags $widget->setUnmatchedProperty('tags', $media->tags); } else { $widget->setUnmatchedProperty('name', $widget->getOptionValue('name', null) ?: $module->name); $widget->setUnmatchedProperty('tags', []); } // Sub-playlists should calculate a fresh duration if ($widget->type === 'subplaylist') { // We know we have a provider class for this module. $widget->calculateDuration($module); } if (in_array('widget_validity', $embed)) { $status = 0; $layout->assessWidgetStatus($module, $widget, $status); $widget->isValid = $status === 1; } // apply default transitions to a dynamic parameters on widget object. if ($layout->autoApplyTransitions == 1) { $widgetTransIn = $widget->getOptionValue('transIn', $this->getConfig()->getSetting('DEFAULT_TRANSITION_IN')); $widgetTransOut = $widget->getOptionValue('transOut', $this->getConfig()->getSetting('DEFAULT_TRANSITION_OUT')); $widgetTransInDuration = $widget->getOptionValue('transInDuration', $this->getConfig()->getSetting('DEFAULT_TRANSITION_DURATION')); $widgetTransOutDuration = $widget->getOptionValue('transOutDuration', $this->getConfig()->getSetting('DEFAULT_TRANSITION_DURATION')); } else { $widgetTransIn = $widget->getOptionValue('transIn', null); $widgetTransOut = $widget->getOptionValue('transOut', null); $widgetTransInDuration = $widget->getOptionValue('transInDuration', null); $widgetTransOutDuration = $widget->getOptionValue('transOutDuration', null); } $widget->transitionIn = $widgetTransIn; $widget->transitionOut = $widgetTransOut; $widget->transitionDurationIn = $widgetTransInDuration; $widget->transitionDurationOut = $widgetTransOutDuration; if (in_array('permissions', $embed)) { // Augment with editable flag $widget->setUnmatchedProperty('isEditable', $this->getUser()->checkEditable($widget)); // Augment with deletable flag $widget->setUnmatchedProperty('isDeletable', $this->getUser()->checkDeleteable($widget)); // Augment with viewable flag $widget->setUnmatchedProperty('isViewable', $this->getUser()->checkViewable($widget)); // Augment with permissions flag $widget->setUnmatchedProperty( 'isPermissionsModifiable', $this->getUser()->checkPermissionsModifyable($widget) ); } } /** @var Region[] $allRegions */ $allRegions = array_merge($layout->regions, $layout->drawers); // Augment regions with permissions foreach ($allRegions as $region) { if (in_array('permissions', $embed)) { // Augment with editable flag $region->setUnmatchedProperty('isEditable', $this->getUser()->checkEditable($region)); // Augment with deletable flag $region->setUnmatchedProperty('isDeletable', $this->getUser()->checkDeleteable($region)); // Augment with viewable flag $region->setUnmatchedProperty('isViewable', $this->getUser()->checkViewable($region)); // Augment with permissions flag $region->setUnmatchedProperty( 'isPermissionsModifiable', $this->getUser()->checkPermissionsModifyable($region) ); } } } if ($this->isApi($request)) { continue; } $layout->includeProperty('buttons'); // Thumbnail $layout->setUnmatchedProperty('thumbnail', ''); if (file_exists($layout->getThumbnailUri())) { $layout->setUnmatchedProperty( 'thumbnail', $this->urlFor($request, 'layout.download.thumbnail', ['id' => $layout->layoutId]) ); } // Fix up the description $layout->setUnmatchedProperty('descriptionFormatted', $layout->description); if ($layout->description != '') { if ($showDescriptionId == 1) { // Parse down for description $layout->setUnmatchedProperty( 'descriptionFormatted', Parsedown::instance()->setSafeMode(true)->text($layout->description) ); } else if ($showDescriptionId == 2) { $layout->setUnmatchedProperty('descriptionFormatted', strtok($layout->description, "\n")); } } if ($showDescriptionId === 3) { // Load in the entire object model - creating module objects so that we can get the name of each // widget and its items. foreach ($layout->regions as $region) { foreach ($region->getPlaylist()->widgets as $widget) { $module = $this->moduleFactory->getByType($widget->type); $widget->setUnmatchedProperty('moduleName', $module->name); $widget->setUnmatchedProperty('name', $widget->getOptionValue('name', $module->name)); } } // provide our layout object to a template to render immediately $layout->setUnmatchedProperty('descriptionFormatted', $this->renderTemplateToString( 'layout-page-grid-widgetlist', (array)$layout )); } $layout->setUnmatchedProperty('statusDescription', match ($layout->status) { Status::$STATUS_VALID => __('This Layout is ready to play'), Status::$STATUS_PLAYER => __('There are items on this Layout that can only be assessed by the Display'), Status::$STATUS_NOT_BUILT => __('This Layout has not been built yet'), default => __('This Layout is invalid and should not be scheduled'), }); $layout->setUnmatchedProperty('enableStatDescription', match ($layout->enableStat) { 1 => __('This Layout has enable stat collection set to ON'), default => __('This Layout has enable stat collection set to OFF'), }); // Check if user has "delete permissions" - for layout designer to show/hide Delete button $layout->setUnmatchedProperty('deletePermission', $this->getUser()->featureEnabled('layout.modify')); // Check if user has view permissions to the schedule now page - for layout designer to show/hide // the Schedule Now button $layout->setUnmatchedProperty('scheduleNowPermission', $this->getUser()->featureEnabled('schedule.add')); // Add some buttons for this row if ($this->getUser()->featureEnabled('layout.modify') && $this->getUser()->checkEditable($layout) ) { // Design Button $layout->buttons[] = array( 'id' => 'layout_button_design', 'linkType' => '_self', 'external' => true, 'url' => $this->urlFor($request, 'layout.designer', array('id' => $layout->layoutId)), 'text' => __('Design') ); // Should we show a publish/discard button? if ($layout->isEditable()) { $layout->buttons[] = ['divider' => true]; $layout->buttons[] = array( 'id' => 'layout_button_publish', 'url' => $this->urlFor($request, 'layout.publish.form', ['id' => $layout->layoutId]), 'text' => __('Publish') ); $layout->buttons[] = array( 'id' => 'layout_button_discard', 'url' => $this->urlFor($request, 'layout.discard.form', ['id' => $layout->layoutId]), 'text' => __('Discard') ); $layout->buttons[] = ['divider' => true]; } else { $layout->buttons[] = ['divider' => true]; // Checkout Button $layout->buttons[] = array( 'id' => 'layout_button_checkout', 'url' => $this->urlFor($request, 'layout.checkout.form', ['id' => $layout->layoutId]), 'text' => __('Checkout'), 'dataAttributes' => [ ['name' => 'auto-submit', 'value' => true], ['name' => 'commit-url', 'value' => $this->urlFor($request, 'layout.checkout', ['id' => $layout->layoutId])], ['name' => 'commit-method', 'value' => 'PUT'] ] ); $layout->buttons[] = ['divider' => true]; } } // Preview if ($this->getUser()->featureEnabled('layout.view')) { $layout->buttons[] = array( 'id' => 'layout_button_preview', 'external' => true, 'url' => '#', 'onclick' => 'createMiniLayoutPreview', 'onclickParam' => $this->urlFor($request, 'layout.preview', ['id' => $layout->layoutId]), 'text' => __('Preview Layout') ); // Also offer a way to preview the draft layout. if ($layout->hasDraft()) { $layout->buttons[] = array( 'id' => 'layout_button_preview_draft', 'external' => true, 'url' => '#', 'onclick' => 'createMiniLayoutPreview', 'onclickParam' => $this->urlFor($request, 'layout.preview', ['id' => $layout->layoutId]) . '?isPreviewDraft=true', 'text' => __('Preview Draft Layout') ); } $layout->buttons[] = ['divider' => true]; } // Schedule if ($this->getUser()->featureEnabled('schedule.add')) { $layout->buttons[] = array( 'id' => 'layout_button_schedule', 'url' => $this->urlFor($request, 'schedule.add.form', ['id' => $layout->campaignId, 'from' => 'Layout']), 'text' => __('Schedule') ); } // Assign to Campaign if ($this->getUser()->featureEnabled('campaign.modify')) { $layout->buttons[] = array( 'id' => 'layout_button_assignTo_campaign', 'url' => $this->urlFor($request, 'layout.assignTo.campaign.form', ['id' => $layout->layoutId]), 'text' => __('Assign to Campaign') ); } $layout->buttons[] = ['divider' => true]; if ($this->getUser()->featureEnabled('playlist.view')) { $layout->buttons[] = [ 'id' => 'layout_button_playlist_jump', 'linkType' => '_self', 'external' => true, 'url' => $this->urlFor($request, 'playlist.view') .'?layoutId=' . $layout->layoutId, 'text' => __('Jump to Playlists included on this Layout') ]; } if ($this->getUser()->featureEnabled('campaign.view')) { $layout->buttons[] = [ 'id' => 'layout_button_campaign_jump', 'linkType' => '_self', 'external' => true, 'url' => $this->urlFor($request, 'campaign.view') .'?layoutId=' . $layout->layoutId, 'text' => __('Jump to Campaigns containing this Layout') ]; } if ($this->getUser()->featureEnabled('library.view')) { $layout->buttons[] = [ 'id' => 'layout_button_media_jump', 'linkType' => '_self', 'external' => true, 'url' => $this->urlFor($request, 'library.view') .'?layoutId=' . $layout->layoutId, 'text' => __('Jump to Media included on this Layout') ]; } $layout->buttons[] = ['divider' => true]; // Only proceed if we have edit permissions if ($this->getUser()->featureEnabled('layout.modify') && $this->getUser()->checkEditable($layout) ) { // Edit Button $layout->buttons[] = array( 'id' => 'layout_button_edit', 'url' => $this->urlFor($request, 'layout.edit.form', ['id' => $layout->layoutId]), 'text' => __('Edit') ); if ($this->getUser()->featureEnabled('folder.view')) { // Select Folder $layout->buttons[] = [ 'id' => 'campaign_button_selectfolder', 'url' => $this->urlFor($request, 'campaign.selectfolder.form', ['id' => $layout->campaignId]), 'text' => __('Select Folder'), 'multi-select' => true, 'dataAttributes' => [ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'campaign.selectfolder', ['id' => $layout->campaignId])], ['name' => 'commit-method', 'value' => 'put'], ['name' => 'id', 'value' => 'campaign_button_selectfolder'], ['name' => 'text', 'value' => __('Move to Folder')], ['name' => 'rowtitle', 'value' => $layout->layout], ['name' => 'form-callback', 'value' => 'moveFolderMultiSelectFormOpen'] ] ]; } // Copy Button $layout->buttons[] = array( 'id' => 'layout_button_copy', 'url' => $this->urlFor($request, 'layout.copy.form', ['id' => $layout->layoutId]), 'text' => __('Copy') ); // Retire Button if ($layout->retired == 0) { $layout->buttons[] = [ 'id' => 'layout_button_retire', 'url' => $this->urlFor($request, 'layout.retire.form', ['id' => $layout->layoutId]), 'text' => __('Retire'), 'multi-select' => true, 'dataAttributes' => [ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'layout.retire', ['id' => $layout->layoutId])], ['name' => 'commit-method', 'value' => 'put'], ['name' => 'id', 'value' => 'layout_button_retire'], ['name' => 'text', 'value' => __('Retire')], ['name' => 'sort-group', 'value' => 1], ['name' => 'rowtitle', 'value' => $layout->layout] ] ]; } else { $layout->buttons[] = array( 'id' => 'layout_button_unretire', 'url' => $this->urlFor($request, 'layout.unretire.form', ['id' => $layout->layoutId]), 'text' => __('Unretire'), ); } // Extra buttons if have delete permissions if ($this->getUser()->checkDeleteable($layout)) { // Delete Button $layout->buttons[] = [ 'id' => 'layout_button_delete', 'url' => $this->urlFor($request, 'layout.delete.form', ['id' => $layout->layoutId]), 'text' => __('Delete'), 'multi-select' => true, 'dataAttributes' => [ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'layout.delete', ['id' => $layout->layoutId])], ['name' => 'commit-method', 'value' => 'delete'], ['name' => 'id', 'value' => 'layout_button_delete'], ['name' => 'text', 'value' => __('Delete')], ['name' => 'sort-group', 'value' => 1], ['name' => 'rowtitle', 'value' => $layout->layout] ] ]; } // Set Enable Stat $layout->buttons[] = [ 'id' => 'layout_button_setenablestat', 'url' => $this->urlFor($request, 'layout.setenablestat.form', ['id' => $layout->layoutId]), 'text' => __('Enable stats collection?'), 'multi-select' => true, 'dataAttributes' => [ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'layout.setenablestat', ['id' => $layout->layoutId])], ['name' => 'commit-method', 'value' => 'put'], ['name' => 'id', 'value' => 'layout_button_setenablestat'], ['name' => 'text', 'value' => __('Enable stats collection?')], ['name' => 'rowtitle', 'value' => $layout->layout], ['name' => 'form-callback', 'value' => 'setEnableStatMultiSelectFormOpen'] ] ]; $layout->buttons[] = ['divider' => true]; if ($this->getUser()->featureEnabled('template.modify') && !$layout->isEditable()) { // Save template button $layout->buttons[] = array( 'id' => 'layout_button_save_template', 'url' => $this->urlFor($request, 'template.from.layout.form', ['id' => $layout->layoutId]), 'text' => __('Save Template') ); } // Export Button if ($this->getUser()->featureEnabled('layout.export')) { $layout->buttons[] = array( 'id' => 'layout_button_export', 'url' => $this->urlFor($request, 'layout.export.form', ['id' => $layout->layoutId]), 'text' => __('Export') ); } // Extra buttons if we have modify permissions if ($this->getUser()->checkPermissionsModifyable($layout)) { // Permissions button $layout->buttons[] = [ 'id' => 'layout_button_permissions', 'url' => $this->urlFor($request, 'user.permissions.form', ['entity' => 'Campaign', 'id' => $layout->campaignId]), 'text' => __('Share'), 'multi-select' => true, 'dataAttributes' => [ ['name' => 'commit-url', 'value' => $this->urlFor($request, 'user.permissions.multi', ['entity' => 'Campaign', 'id' => $layout->campaignId])], ['name' => 'commit-method', 'value' => 'post'], ['name' => 'id', 'value' => 'layout_button_permissions'], ['name' => 'text', 'value' => __('Share')], ['name' => 'rowtitle', 'value' => $layout->layout], ['name' => 'sort-group', 'value' => 2], ['name' => 'custom-handler', 'value' => 'XiboMultiSelectPermissionsFormOpen'], ['name' => 'custom-handler-url', 'value' => $this->urlFor($request, 'user.permissions.multi.form', ['entity' => 'Campaign'])], ['name' => 'content-id-name', 'value' => 'campaignId'] ] ]; } } } // Store the table rows $this->getState()->recordsTotal = $this->layoutFactory->countLast(); $this->getState()->setData($layouts); return $this->render($request, $response); } /** * Edit form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function editForm(Request $request, Response $response, $id) { // Get the layout $layout = $this->layoutFactory->getById($id); // Check Permissions if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(); } $this->getState()->template = 'layout-form-edit'; $this->getState()->setData([ 'layout' => $layout, 'tagString' => $layout->getTagString(), ]); return $this->render($request, $response); } /** * Edit form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ function editBackgroundForm(Request $request, Response $response, $id) { // Get the layout $layout = $this->layoutFactory->getById($id); $sanitizedParams = $this->getSanitizer($request->getParams()); // Check Permissions if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(); } // Edits always happen on Drafts, get the draft Layout using the Parent Layout ID if ($layout->schemaVersion < 2) { $resolution = $this->resolutionFactory->getByDesignerDimensions($layout->width, $layout->height); } else { $resolution = $this->resolutionFactory->getByDimensions($layout->width, $layout->height); } // If we have a background image, output it $backgroundId = $sanitizedParams->getInt('backgroundOverride', ['default' => $layout->backgroundImageId]); $backgrounds = ($backgroundId != null) ? [$this->mediaFactory->getById($backgroundId)] : []; $this->getState()->template = 'layout-form-background'; $this->getState()->setData([ 'layout' => $layout, 'resolution' => $resolution, 'resolutions' => $this->resolutionFactory->query( ['resolution'], [ 'withCurrent' => $resolution->resolutionId, 'enabled' => 1 ] ), 'backgroundId' => $backgroundId, 'backgrounds' => $backgrounds, ]); return $this->render($request, $response); } /** * Copy layout form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function copyForm(Request $request, Response $response, $id) { // Get the layout $layout = $this->layoutFactory->getById($id); // Check Permissions if (!$this->getUser()->checkViewable($layout)) throw new AccessDeniedException(); $this->getState()->template = 'layout-form-copy'; $this->getState()->setData([ 'layout' => $layout, ]); return $this->render($request, $response); } /** * Copies a layout * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ConfigurationException * @throws \Xibo\Support\Exception\ControllerNotImplemented * @throws \Xibo\Support\Exception\DuplicateEntityException * @SWG\Post( * path="/layout/copy/{layoutId}", * operationId="layoutCopy", * tags={"layout"}, * summary="Copy Layout", * description="Copy a Layout, providing a new name if applicable", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout ID to Copy", * type="integer", * required=true * ), * @SWG\Parameter( * name="name", * in="formData", * description="The name for the new Layout", * type="string", * required=true * ), * @SWG\Parameter( * name="description", * in="formData", * description="The Description for the new Layout", * type="string", * required=false * ), * @SWG\Parameter( * name="copyMediaFiles", * in="formData", * description="Flag indicating whether to make new Copies of all Media Files assigned to the Layout being Copied", * type="integer", * required=true * ), * @SWG\Response( * response=201, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout"), * @SWG\Header( * header="Location", * description="Location of the new record", * type="string" * ) * ) * ) */ public function copy(Request $request, Response $response, $id) { // Get the layout $originalLayout = $this->layoutFactory->getById($id); $sanitizedParams = $this->getSanitizer($request->getParams()); // Check Permissions if (!$this->getUser()->checkViewable($originalLayout)) { throw new AccessDeniedException(); } // Make sure we're not a draft if ($originalLayout->isChild()) { throw new InvalidArgumentException(__('Cannot copy a Draft Layout'), 'layoutId'); } // Load the layout for Copy $originalLayout->load(); // Clone $layout = clone $originalLayout; $this->getLog()->debug('Tag values from original layout: ' . $originalLayout->getTagString()); $layout->layout = $sanitizedParams->getString('name'); $layout->description = $sanitizedParams->getString('description'); $layout->updateTagLinks($originalLayout->tags); $layout->setOwner($this->getUser()->userId, true); // Copy the media on the layout and change the assignments. // https://github.com/xibosignage/xibo/issues/1283 if ($sanitizedParams->getCheckbox('copyMediaFiles') == 1) { // track which Media Id we already copied $copiedMediaIds = []; foreach ($layout->getAllWidgets() as $widget) { // Copy the media if ( $widget->type === 'image' || $widget->type === 'video' || $widget->type === 'pdf' || $widget->type === 'powerpoint' || $widget->type === 'audio' ) { $oldMedia = $this->mediaFactory->getById($widget->getPrimaryMediaId()); // check if we already cloned this media, if not, do it and add it the array if (!array_key_exists($oldMedia->mediaId, $copiedMediaIds)) { $media = clone $oldMedia; $media->setOwner($this->getUser()->userId); $media->save(); $copiedMediaIds[$oldMedia->mediaId] = $media->mediaId; } else { // if we already cloned that media, look it up and assign to Widget. $mediaId = $copiedMediaIds[$oldMedia->mediaId]; $media = $this->mediaFactory->getById($mediaId); } $widget->unassignMedia($oldMedia->mediaId); $widget->assignMedia($media->mediaId); // Update the widget option with the new ID $widget->setOptionValue('uri', 'attrib', $media->storedAs); } } // Also handle the background image, if there is one if ($layout->backgroundImageId != 0) { $oldMedia = $this->mediaFactory->getById($layout->backgroundImageId); // check if we already cloned this media, if not, do it and add it the array if (!array_key_exists($oldMedia->mediaId, $copiedMediaIds)) { $media = clone $oldMedia; $media->setOwner($this->getUser()->userId); $media->save(); $copiedMediaIds[$oldMedia->mediaId] = $media->mediaId; } else { // if we already cloned that media, look it up and assign to Layout backgroundImage. $mediaId = $copiedMediaIds[$oldMedia->mediaId]; $media = $this->mediaFactory->getById($mediaId); } $layout->backgroundImageId = $media->mediaId; } } // Save the new layout $layout->save(); $allRegions = array_merge($layout->regions, $layout->drawers); // this will adjust source/target Ids in the copied layout $layout->copyActions($layout, $originalLayout); // Sub-Playlist /** @var Region $region */ foreach ($allRegions as $region) { // Match our original region id to the id in the parent layout $original = $originalLayout->getRegionOrDrawer($region->getOriginalValue('regionId')); // Make sure Playlist closure table from the published one are copied over $original->getPlaylist()->cloneClosureTable($region->getPlaylist()->playlistId); } // Return $this->getState()->hydrate([ 'httpStatus' => 201, 'message' => sprintf(__('Copied as %s'), $layout->layout), 'id' => $layout->layoutId, 'data' => $layout ]); return $this->render($request, $response); } /** * @SWG\Post( * path="/layout/{layoutId}/tag", * operationId="layoutTag", * tags={"layout"}, * summary="Tag Layout", * description="Tag a Layout with one or more tags", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout Id to Tag", * type="integer", * required=true * ), * @SWG\Parameter( * name="tag", * in="formData", * description="An array of tags", * type="array", * required=true, * @SWG\Items(type="string") * ), * @SWG\Response( * response=200, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout") * ) * ) * * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function tag(Request $request, Response $response, $id) { // Edit permission // Get the layout $layout = $this->layoutFactory->getById($id); $sanitizedParams = $this->getSanitizer($request->getParams()); // Check Permissions if (!$this->getUser()->checkEditable($layout)) throw new AccessDeniedException(); // Make sure we're not a draft if ($layout->isChild()) throw new InvalidArgumentException(__('Cannot manage tags on a Draft Layout'), 'layoutId'); $tags = $sanitizedParams->getArray('tag'); if (count($tags) <= 0) { throw new InvalidArgumentException(__('No tags to assign')); } foreach ($tags as $tag) { $layout->assignTag($this->tagFactory->tagFromString($tag)); } $layout->save(); // Return $this->getState()->hydrate([ 'message' => sprintf(__('Tagged %s'), $layout->layout), 'id' => $layout->layoutId, 'data' => $layout ]); return $this->render($request, $response); } /** * @SWG\Post( * path="/layout/{layoutId}/untag", * operationId="layoutUntag", * tags={"layout"}, * summary="Untag Layout", * description="Untag a Layout with one or more tags", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout Id to Untag", * type="integer", * required=true * ), * @SWG\Parameter( * name="tag", * in="formData", * description="An array of tags", * type="array", * required=true, * @SWG\Items(type="string") * ), * @SWG\Response( * response=200, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout") * ) * ) * * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function untag(Request $request, Response $response, $id) { // Edit permission // Get the layout $layout = $this->layoutFactory->getById($id); $sanitizedParams = $this->getSanitizer($request->getParams()); // Check Permissions if (!$this->getUser()->checkEditable($layout)) throw new AccessDeniedException(); // Make sure we're not a draft if ($layout->isChild()) throw new InvalidArgumentException(__('Cannot manage tags on a Draft Layout'), 'layoutId'); $tags = $sanitizedParams->getArray('tag'); if (count($tags) <= 0) throw new InvalidArgumentException(__('No tags to unassign'), 'tag'); foreach ($tags as $tag) { $layout->unassignTag($this->tagFactory->tagFromString($tag)); } $layout->save(); // Return $this->getState()->hydrate([ 'message' => sprintf(__('Untagged %s'), $layout->layout), 'id' => $layout->layoutId, 'data' => $layout ]); return $this->render($request, $response); } /** * Layout Status * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented * @SWG\Get( * path="/layout/status/{layoutId}", * operationId="layoutStatus", * tags={"layout"}, * summary="Layout Status", * description="Calculate the Layout status and return a Layout", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout Id to get the status", * type="integer", * required=true * ), * @SWG\Response( * response=200, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout") * ) * ) */ public function status(Request $request, Response $response, $id) { // Get the layout $layout = $this->layoutFactory->concurrentRequestLock($this->layoutFactory->getById($id)); try { $layout = $this->layoutFactory->decorateLockedProperties($layout); $layout->xlfToDisk(); } finally { // Release lock $this->layoutFactory->concurrentRequestRelease($layout); } switch ($layout->status) { case Status::$STATUS_VALID: $status = __('This Layout is ready to play'); break; case Status::$STATUS_PLAYER: $status = __('There are items on this Layout that can only be assessed by the Display'); break; case Status::$STATUS_NOT_BUILT: $status = __('This Layout has not been built yet'); break; default: $status = __('This Layout is invalid and should not be scheduled'); } // We want a different return depending on whether we are arriving through the API or WEB routes if ($this->isApi($request)) { $this->getState()->hydrate([ 'httpStatus' => 200, 'message' => $status, 'id' => $layout->status, 'data' => $layout ]); } else { $this->getState()->html = $status; $this->getState()->extra = [ 'status' => $layout->status, 'duration' => $layout->duration, 'statusMessage' => $layout->getStatusMessage(), 'isLocked' => $layout->isLocked ]; $this->getState()->success = true; $this->session->refreshExpiry = false; } return $this->render($request, $response); } /** * Export Form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function exportForm(Request $request, Response $response, $id) { // Get the layout $layout = $this->layoutFactory->getById($id); // Check Permissions if (!$this->getUser()->checkViewable($layout)) throw new AccessDeniedException(); // Make sure we're not a draft if ($layout->isChild()) { throw new InvalidArgumentException(__('Cannot export Draft Layout'), 'layoutId'); } // Render the form $this->getState()->template = 'layout-form-export'; $this->getState()->setData([ 'layout' => $layout, 'saveAs' => 'export_' . preg_replace('/[^a-z0-9]+/', '-', strtolower($layout->layout)) ]); return $this->render($request, $response); } /** * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException */ public function export(Request $request, Response $response, $id) { $this->setNoOutput(true); // Get the layout $layout = $this->layoutFactory->getById($id); $sanitizedParams = $this->getSanitizer($request->getParams()); // Check Permissions if (!$this->getUser()->checkViewable($layout)) { throw new AccessDeniedException(); } // Make sure we're not a draft if ($layout->isChild()) { throw new InvalidArgumentException(__('Cannot export Draft Layout'), 'layoutId'); } // Save As? $saveAs = $sanitizedParams->getString('saveAs'); // Make sure our file name is reasonable if (empty($saveAs)) { $saveAs = 'export_' . preg_replace('/[^a-z0-9]+/', '-', strtolower($layout->layout)); } else { $saveAs = preg_replace('/[^a-z0-9]+/', '-', strtolower($saveAs)); } $fileName = $this->getConfig()->getSetting('LIBRARY_LOCATION') . 'temp/' . $saveAs . '.zip'; $layout->toZip( $this->dataSetFactory, $this->widgetDataFactory, $fileName, [ 'includeData' => ($sanitizedParams->getCheckbox('includeData') == 1), 'includeFallback' => ($sanitizedParams->getCheckbox('includeFallback') == 1), ] ); return $this->render($request, SendFile::decorateResponse( $response, $this->getConfig()->getSetting('SENDFILE_MODE'), $fileName )); } /** * TODO: Not sure how to document this. * SWG\Post( * path="/layout/import", * operationId="layoutImport", * tags={"layout"}, * summary="Import Layout", * description="Upload and Import a Layout", * consumes="multipart/form-data", * SWG\Parameter( * name="file", * in="formData", * description="The file", * type="file", * required=true * ), * @SWG\Response( * response=200, * description="successful operation" * ) * ) * * @param Request $request * @param Response $response * @return Response * @throws \Xibo\Support\Exception\GeneralException */ public function import(Request $request, Response $response) { $this->getLog()->debug('Import Layout'); $parsedBody = $this->getSanitizer($request->getParams()); $libraryFolder = $this->getConfig()->getSetting('LIBRARY_LOCATION'); // Make sure the library exists MediaService::ensureLibraryExists($this->getConfig()->getSetting('LIBRARY_LOCATION')); // Make sure there is room in the library $libraryLimit = $this->getConfig()->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024; // Folders $folderId = $parsedBody->getInt('folderId'); if ($folderId === 1) { $this->checkRootFolderAllowSave(); } if (empty($folderId) || !$this->getUser()->featureEnabled('folder.view')) { $folderId = $this->getUser()->homeFolderId; } $options = [ 'userId' => $this->getUser()->userId, 'controller' => $this, 'dataSetFactory' => $this->getDataSetFactory(), 'widgetDataFactory' => $this->widgetDataFactory, 'image_versions' => [], 'accept_file_types' => '/\.zip$/i', 'libraryLimit' => $libraryLimit, 'libraryQuotaFull' => ($libraryLimit > 0 && $this->mediaService->libraryUsage() > $libraryLimit), 'mediaService' => $this->mediaService, 'sanitizerService' => $this->getSanitizerService(), 'folderId' => $folderId, ]; $this->setNoOutput(); // Hand off to the Upload Handler provided by jquery-file-upload new LayoutUploadHandler($libraryFolder . 'temp/', $this->getLog()->getLoggerInterface(), $options); // Explicitly set the Content-Type header to application/json return $response->withHeader('Content-Type', 'application/json'); } /** * Gets a file from the library * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function downloadBackground(Request $request, Response $response, $id) { $this->getLog()->debug('Layout Download background request for layoutId ' . $id); $layout = $this->layoutFactory->getById($id); if (!$this->getUser()->checkViewable($layout)) { throw new AccessDeniedException(); } if ($layout->backgroundImageId == null) { throw new NotFoundException(); } // This media may not be viewable, but we won't check it because the user has permission to view the // layout that it is assigned to. $media = $this->mediaFactory->getById($layout->backgroundImageId); // Make a media module if ($media->mediaType !== 'image') { throw new NotFoundException(__('Layout background must be an image')); } // Hand over to the widget downloader $downloader = new WidgetDownloader( $this->getConfig()->getSetting('LIBRARY_LOCATION'), $this->getConfig()->getSetting('SENDFILE_MODE'), $this->getConfig()->getSetting('DEFAULT_RESIZE_LIMIT', 6000) ); $downloader->useLogger($this->getLog()->getLoggerInterface()); $response = $downloader->imagePreview( $this->getSanitizer([ 'width' => $layout->width, 'height' => $layout->height, 'proportional' => 0, ]), $media->storedAs, $response, ); $this->setNoOutput(true); return $this->render($request, $response); } /** * Assign to Campaign Form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function assignToCampaignForm(Request $request, Response $response, $id) { // Get the layout $layout = $this->layoutFactory->getById($id); // Check Permissions if (!$this->getUser()->checkViewable($layout)) { throw new AccessDeniedException(); } // Render the form $this->getState()->template = 'layout-form-assign-to-campaign'; $this->getState()->setData([ 'layout' => $layout, ]); return $this->render($request, $response); } /** * Checkout Layout Form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function checkoutForm(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } $data = ['layout' => $layout]; $this->getState()->template = 'layout-form-checkout'; $this->getState()->autoSubmit = $this->getAutoSubmit('layoutCheckoutForm'); $this->getState()->setData($data); return $this->render($request, $response); } /** * Checkout Layout * * @SWG\Put( * path="/layout/checkout/{layoutId}", * operationId="layoutCheckout", * tags={"layout"}, * summary="Checkout Layout", * description="Checkout a Layout so that it can be edited. The original Layout will still be played", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout ID", * type="integer", * required=true * ), * @SWG\Response( * response=200, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout") * ) * ) * * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function checkout(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } // Can't checkout a Layout which can already be edited if ($layout->isEditable()) { throw new InvalidArgumentException(__('Layout is already checked out'), 'statusId'); } // Checkout this Layout $draft = $this->layoutFactory->checkoutLayout($layout); // Return $this->getState()->hydrate([ 'httpStatus' => 200, 'message' => sprintf(__('Checked out %s'), $layout->layout), 'id' => $draft->layoutId, 'data' => $draft ]); return $this->render($request, $response); } /** * Publish Layout Form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function publishForm(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } $data = ['layout' => $layout]; $this->getState()->template = 'layout-form-publish'; $this->getState()->setData($data); return $this->render($request, $response); } /** * Publish Layout * * @SWG\Put( * path="/layout/publish/{layoutId}", * operationId="layoutPublish", * tags={"layout"}, * summary="Publish Layout", * description="Publish a Layout, discarding the original", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout ID", * type="integer", * required=true * ), * @SWG\Parameter( * name="publishNow", * in="formData", * description="Flag, indicating whether to publish layout now", * type="integer", * required=false * ), * @SWG\Parameter( * name="publishDate", * in="formData", * description="The date/time at which layout should be published", * type="string", * required=false * ), * @SWG\Response( * response=200, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout") * ) * ) * * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function publish(Request $request, Response $response, $id) { Profiler::start('Layout::publish', $this->getLog()); $layout = $this->layoutFactory->concurrentRequestLock($this->layoutFactory->getById($id), true); try { $sanitizedParams = $this->getSanitizer($request->getParams()); $publishDate = $sanitizedParams->getDate('publishDate'); $publishNow = $sanitizedParams->getCheckbox('publishNow'); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } // if we have publish date update it in database if (isset($publishDate) && !$publishNow) { $layout->setPublishedDate($publishDate); } // We want to take the draft layout, and update the campaign links to point to the draft, then remove the // parent. if ($publishNow || (isset($publishDate) && $publishDate->format('U') < Carbon::now()->format('U'))) { $draft = $this->layoutFactory->getByParentId($id); $draft->publishDraft(); $draft->load(); // Make sure actions from all levels are valid before allowing publish // Layout Actions foreach ($draft->actions as $action) { $action->validate(); } /** @var Region[] $allRegions */ $allRegions = array_merge($draft->regions, $draft->drawers); // Region Actions foreach ($allRegions as $region) { // Interactive Actions on Region foreach ($region->actions as $action) { $action->validate(); } // Widget Actions foreach ($region->getPlaylist()->widgets as $widget) { // Interactive Actions on Widget foreach ($widget->actions as $action) { $action->validate(); } } } // We also build the XLF at this point, and if we have a problem we prevent publishing and raise as an // error message $draft->xlfToDisk(['notify' => true, 'exceptionOnError' => true, 'exceptionOnEmptyRegion' => false]); // Return $this->getState()->hydrate([ 'httpStatus' => 200, 'message' => sprintf(__('Published %s'), $draft->layout), 'data' => $draft ]); } else { // Return $this->getState()->hydrate([ 'httpStatus' => 200, 'message' => sprintf(__('Layout will be published on %s'), $publishDate), 'data' => $layout ]); } Profiler::end('Layout::publish', $this->getLog()); } finally { // Release lock $this->layoutFactory->concurrentRequestRelease($layout, true); } return $this->render($request, $response); } /** * Discard Layout Form * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function discardForm(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } $data = ['layout' => $layout]; $this->getState()->template = 'layout-form-discard'; $this->getState()->setData($data); return $this->render($request, $response); } /** * Discard Layout * * @SWG\Put( * path="/layout/discard/{layoutId}", * operationId="layoutDiscard", * tags={"layout"}, * summary="Discard Layout", * description="Discard a Layout restoring the original", * @SWG\Parameter( * name="layoutId", * in="path", * description="The Layout ID", * type="integer", * required=true * ), * @SWG\Response( * response=200, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout") * ) * ) * * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws AccessDeniedException * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function discard(Request $request, Response $response, $id) { $layout = $this->layoutFactory->getById($id); // Make sure we have permission if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(__('You do not have permissions to edit this layout')); } // Make sure the Layout is checked out to begin with if (!$layout->isEditable()) { throw new InvalidArgumentException(__('Layout is not checked out'), 'statusId'); } $draft = $this->layoutFactory->getByParentId($id); $draft->discardDraft(); // The parent is no longer a draft $layout->publishedStatusId = 1; // Return $this->getState()->hydrate([ 'httpStatus' => 200, 'message' => sprintf(__('Discarded %s'), $draft->layout), 'data' => $layout ]); return $this->render($request, $response); } /** * Query the Database for all Code identifiers assigned to Layouts. * * @param Request $request * @param Response $response * @return Response * @throws GeneralException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function getLayoutCodes(Request $request, Response $response) { $parsedParams = $this->getSanitizer($request->getQueryParams()); $codes = $this->layoutFactory->getLayoutCodes($this->gridRenderFilter([ 'code' => $parsedParams->getString('code') ], $parsedParams)); // Store the table rows $this->getState()->template = 'grid'; $this->getState()->recordsTotal = $this->layoutFactory->countLast(); $this->getState()->setData($codes); return $this->render($request, $response); } /** * Release the Layout Lock on specified layoutId * Available only to the User that currently has the Layout locked. * * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws GeneralException * @throws InvalidArgumentException * @throws \Xibo\Support\Exception\ControllerNotImplemented */ public function releaseLock(Request $request, Response $response, $id) { /** @var Item $lock */ $lock = $this->pool->getItem('locks/layout/' . $id); $lockUserId = $lock->get()->userId; if ($this->getUser()->userId !== $lockUserId) { throw new InvalidArgumentException(__('This function is available only to User who originally locked this Layout.')); } $lock->set([]); $lock->save(); return $this->render($request, $response); } /** * Add a thumbnail * @param Request $request * @param Response $response * @param $id * @return Response * @throws \Xibo\Support\Exception\AccessDeniedException * @throws \Xibo\Support\Exception\InvalidArgumentException * @throws \Xibo\Support\Exception\NotFoundException * @throws \Xibo\Support\Exception\ConfigurationException */ public function addThumbnail(Request $request, Response $response, $id): Response { $libraryLocation = $this->getConfig()->getSetting('LIBRARY_LOCATION'); MediaService::ensureLibraryExists($libraryLocation); // Check the Layout $layout = $this->layoutFactory->getById($id); // Make sure we have edit permissions if (!$this->getUser()->checkEditable($layout)) { throw new AccessDeniedException(); } // Where would we save this to? if ($layout->isChild()) { // A draft $saveTo = $libraryLocation . 'thumbs/' . $layout->campaignId . '_layout_thumb.png'; } else { // Published // we would usually expect this to be copied over when published. $saveTo = $libraryLocation . 'thumbs/' . $layout->campaignId . '_campaign_thumb.png'; } // Load this Layout $layout->load(); // Create a thumbnail image try { Img::configure(['driver' => 'gd']); if ($layout->backgroundImageId !== null && $layout->backgroundImageId !== 0) { // Start from a background image $media = $this->mediaFactory->getById($layout->backgroundImageId); $image = Img::make($libraryLocation . $media->storedAs); // Resize this image (without cropping it) to the size of this layout $image->resize($layout->width, $layout->height); } else { // Start from a Canvas $image = Img::canvas($layout->width, $layout->height, $layout->backgroundColor); } $countRegions = count($layout->regions); // Draw some regions on it. foreach ($layout->regions as $region) { try { // We don't do this for the canvas region. if ($countRegions > 1 && $region->type === 'canvas') { continue; } // Get widgets in this region $playlist = $region->getPlaylist()->setModuleFactory($this->moduleFactory); $widgets = $playlist->expandWidgets(); if (count($widgets) <= 0) { // Render the region (draw a grey box) $image->rectangle( $region->left, $region->top, $region->left + $region->width, $region->top + $region->height, function ($draw) { $draw->background('rgba(196, 196, 196, 0.6)'); } ); if ($region->width >= 400) { $image->text( __('Empty Region'), $region->left + ($region->width / 2), $region->top + ($region->height / 2), function ($font) { $font->file(PROJECT_ROOT . '/web/theme/default/fonts/Railway.ttf'); $font->size(84); $font->color('#000000'); $font->align('center'); $font->valign('center'); } ); } } else { // Render just the first widget in the appropriate place $widget = $widgets[0]; if ($widget->type === 'image') { $media = $this->mediaFactory->getById($widget->getPrimaryMediaId()); $cover = Img::make($libraryLocation . $media->storedAs); $proportional = $widget->getOptionValue('scaleType', 'stretch') !== 'stretch'; $fit = $widget->getOptionValue('scaleType', 'stretch') === 'fit'; if ($fit) { $cover->fit($region->width, $region->height); } else { $cover->resize( $region->width, $region->height, function ($constraint) use ($proportional) { if ($proportional) { $constraint->aspectRatio(); } } ); } if ($proportional) { $cover->resizeCanvas($region->width, $region->height); } $image->insert($cover, 'top-left', $region->left, $region->top); } else if ($widget->type === 'video' && file_exists($libraryLocation . $widget->getPrimaryMediaId() . '_videocover.png') ) { // Render the video cover $cover = Img::make($libraryLocation . $widget->getPrimaryMediaId() . '_videocover.png'); $cover->resize($region->width, $region->height, function ($constraint) { $constraint->aspectRatio(); }); $cover->resizeCanvas($region->width, $region->height); $image->insert($cover, 'top-left', $region->left, $region->top); } else { // Draw the region in the widget colouring $image->rectangle( $region->left, $region->top, $region->left + $region->width, $region->top + $region->height, function ($draw) { $draw->background('rgba(196, 196, 196, 0.6)'); } ); $module = $this->moduleFactory->getByType($widget->type); if ($region->width >= 400) { $image->text( $widget->getOptionValue('name', $module->name), $region->left + ($region->width / 2), $region->top + ($region->height / 2), function ($font) { $font->file(PROJECT_ROOT . '/web/theme/default/fonts/Railway.ttf'); $font->size(84); $font->color('#000000'); $font->align('center'); $font->valign('center'); } ); } } // Put a number of widgets counter in the bottom $image->text( '1 / ' . count($widgets), $region->left + $region->width - 10, $region->top + $region->height - 10, function ($font) { $font->file(PROJECT_ROOT . '/web/theme/default/fonts/Railway.ttf'); $font->size(36); $font->color('#000000'); $font->align('right'); $font->valign('bottom'); } ); } } catch (\Exception $e) { $this->getLog()->error('Problem generating region in thumbnail. e: ' . $e->getMessage()); } } // Resize the entire layout down to a thumbnail $image->widen(1080); // Save the file $image->save($saveTo); return $response->withStatus(204); } catch (\Exception $e) { $this->getLog()->error('Exception adding thumbnail to Layout. e = ' . $e->getMessage()); throw new InvalidArgumentException(__('Incorrect image data')); } } /** * Download the Layout Thumbnail * @param Request $request * @param Response $response * @param $id * @return \Psr\Http\Message\ResponseInterface|Response * @throws \Xibo\Support\Exception\GeneralException */ public function downloadThumbnail(Request $request, Response $response, $id) { $this->getLog()->debug('Layout thumbnail request for layoutId ' . $id); $layout = $this->layoutFactory->getById($id); if (!$this->getUser()->checkViewable($layout)) { throw new AccessDeniedException(); } // Get thumbnail uri $uri = $layout->getThumbnailUri(); if (!file_exists($uri)) { throw new NotFoundException(__('Thumbnail not found for Layout')); } $response = $response ->withHeader('Content-Length', filesize($uri)) ->withHeader('Content-Type', (new MimeTypes())->getMimeType('png')); $sendFileMode = $this->getConfig()->getSetting('SENDFILE_MODE'); if ($sendFileMode == 'Apache') { $response = $response->withHeader('X-Sendfile', $uri); } else if ($sendFileMode == 'Nginx') { $response = $response->withHeader('X-Accel-Redirect', '/download/thumbs/' . basename($uri)); } else { // Return the file with PHP $response = $response->withBody(new Stream(fopen($uri, 'r'))); } $this->setNoOutput(); return $this->render($request, $response); } /** * Create a Layout with full screen Region with Media/Playlist specific Widget * This is called as a first step when scheduling Media/Playlist eventType * @SWG\Post( * path="/layout/fullscreen", * operationId="layoutAddFullScreen", * tags={"layout"}, * summary="Add a Full Screen Layout", * description="Add a new full screen Layout with specified Media/Playlist", * @SWG\Parameter( * name="id", * in="formData", * description="The Media or Playlist ID that should be added to this Layout", * type="integer", * required=true * ), * @SWG\Parameter( * name="type", * in="formData", * description="The type of Layout to be created = media or playlist", * type="string", * required=true * ), * @SWG\Parameter( * name="resolutionId", * in="formData", * description="The Id of the resolution for this Layout, defaults to 1080p for playlist and closest resolution match for Media", * type="integer", * required=false * ), * @SWG\Parameter( * name="backgroundColor", * in="formData", * description="A HEX color to use as the background color of this Layout. Default is black #000", * type="string", * required=false * ), * @SWG\Parameter( * name="layoutDuration", * in="formData", * description="Use with media type, to specify the duration this Media should play in one loop", * type="boolean", * required=false * ), * @SWG\Response( * response=201, * description="successful operation", * @SWG\Schema(ref="#/definitions/Layout"), * @SWG\Header( * header="Location", * description="Location of the new record", * type="string" * ) * ) * ) * @param Request $request * @param Response $response * @return Response|ResponseInterface * @throws GeneralException * @throws InvalidArgumentException * @throws NotFoundException */ public function createFullScreenLayout(Request $request, Response $response): Response|ResponseInterface { $params = $this->getSanitizer($request->getParams()); $type = $params->getString('type'); $id = $params->getInt('id'); $resolutionId = $params->getInt('resolutionId'); $backgroundColor = $params->getString('backgroundColor'); $duration = $params->getInt('layoutDuration'); if (empty($id)) { throw new InvalidArgumentException(sprintf(__('Please select %s'), ucfirst($type))); } // We only create fullscreen layout from media files or playlist if (!in_array($type, ['media', 'playlist'], true)) { throw new InvalidArgumentException(__('Invalid type')); } $fullscreenLayout = $this->layoutFactory->createFullScreenLayout( $type, $id, $resolutionId, $backgroundColor, $duration ); // Return $this->getState()->hydrate([ 'httpStatus' => 200, 'message' => sprintf(__('Created %s'), $fullscreenLayout->layout), 'data' => $fullscreenLayout ]); return $this->render($request, $response); } }