From 8e07966306230a0fc3bea425c5400f6c74509cb7 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Tue, 8 Oct 2024 15:50:17 +0100 Subject: [PATCH 01/13] Initial PWA implementation for ChromeOS (#2613) * XMDS: file downloads should output content type * XMDS - CSP: Add response headers and fix param for soap endpoint * Display Settings: add chromeOS * ChromeOS: uploading .chrome file, unpacking into the library, serving via PWA. relates to xibosignageltd/xibo-private#772 --------- Co-authored-by: Ruben Pingol --- .github/workflows/build-container.yaml | 1 + composer.json | 4 +- ...ult_chromeOS_display_profile_migration.php | 46 ++++ .../apache2/sites-available/000-default.conf | 9 + lib/Controller/Display.php | 2 +- lib/Controller/DisplayProfile.php | 30 ++- lib/Controller/DisplayProfileConfigFields.php | 65 +++++ lib/Controller/PlayerSoftware.php | 14 +- lib/Controller/Pwa.php | 253 ++++++++++++++++++ lib/Dependencies/Controllers.php | 10 + lib/Entity/Display.php | 8 + lib/Entity/PlayerVersion.php | 123 +++++++-- lib/Factory/DisplayProfileFactory.php | 29 +- lib/Factory/PlayerVersionFactory.php | 7 +- lib/Helper/LinkSigner.php | 60 ++++- lib/Middleware/Csp.php | 4 +- lib/Middleware/CustomMiddlewareTrait.php | 26 +- lib/Service/MediaService.php | 6 +- lib/Widget/Render/WidgetDataProviderCache.php | 41 ++- lib/Widget/Render/WidgetHtmlRenderer.php | 148 +++++++++- lib/Xmds/Soap.php | 89 +++--- lib/Xmds/Soap7.php | 15 +- views/display-form-edit.twig | 2 + views/displayprofile-form-edit-chromeos.twig | 176 ++++++++++++ views/displayprofile-form-edit.twig | 4 +- web/.htaccess | 11 + web/pwa/index.php | 114 ++++++++ web/xmds.php | 91 +++++-- 28 files changed, 1256 insertions(+), 132 deletions(-) create mode 100644 db/migrations/20241002121300_add_default_chromeOS_display_profile_migration.php create mode 100644 lib/Controller/Pwa.php create mode 100644 views/displayprofile-form-edit-chromeos.twig create mode 100644 web/pwa/index.php diff --git a/.github/workflows/build-container.yaml b/.github/workflows/build-container.yaml index 55045d86a0..c030e264f5 100644 --- a/.github/workflows/build-container.yaml +++ b/.github/workflows/build-container.yaml @@ -9,6 +9,7 @@ on: - release23 - release33 - release40 + - feature/kopff_chromeos jobs: build: diff --git a/composer.json b/composer.json index fdbb3f6def..56325e6fdf 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,8 @@ "ext-pdo": "1", "ext-json": "1", "ext-soap": "1", - "ext-zip": "1" + "ext-zip": "1", + "ext-fileinfo": "*" } }, "minimum-stability": "dev", @@ -48,6 +49,7 @@ "ext-soap": "*", "ext-zip": "*", "ext-openssl": "*", + "ext-fileinfo": "*", "slim/slim": "^4.3", "slim/http": "^1.2", "slim/flash": "^0.4", diff --git a/db/migrations/20241002121300_add_default_chromeOS_display_profile_migration.php b/db/migrations/20241002121300_add_default_chromeOS_display_profile_migration.php new file mode 100644 index 0000000000..f647e9aecb --- /dev/null +++ b/db/migrations/20241002121300_add_default_chromeOS_display_profile_migration.php @@ -0,0 +1,46 @@ +. + */ + +use Phinx\Migration\AbstractMigration; + +/** + * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace + */ +class AddDefaultChromeOSDisplayProfileMigration extends AbstractMigration +{ + public function change(): void + { + // add default display profile for tizen + if (!$this->fetchRow('SELECT * FROM displayprofile WHERE type = \'chromeOS\' AND isDefault = 1')) { + // Get system user + $user = $this->fetchRow('SELECT userId FROM `user` WHERE userTypeId = 1'); + + $this->table('displayprofile')->insert([ + 'name' => 'ChromeOS', + 'type' => 'chromeOS', + 'config' => '[]', + 'userId' => $user['userId'], + 'isDefault' => 1 + ])->save(); + } + } +} diff --git a/docker/etc/apache2/sites-available/000-default.conf b/docker/etc/apache2/sites-available/000-default.conf index 762f518aa6..8d7da80d16 100644 --- a/docker/etc/apache2/sites-available/000-default.conf +++ b/docker/etc/apache2/sites-available/000-default.conf @@ -36,6 +36,15 @@ ServerTokens OS Require all granted + Alias /chromeos /var/www/cms/library/playersoftware/chromeos/latest + + php_admin_value engine Off + DirectoryIndex index.html + Options -Indexes -FollowSymLinks -MultiViews + AllowOverride None + Require all granted + + ErrorLog /dev/stderr LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" *%Ts* *%Dus*" requesttime diff --git a/lib/Controller/Display.php b/lib/Controller/Display.php index 2c6f0911e5..06a9a0a1d0 100644 --- a/lib/Controller/Display.php +++ b/lib/Controller/Display.php @@ -961,7 +961,7 @@ public function grid(Request $request, Response $response) ]; } - if (in_array($display->clientType, ['android', 'lg', 'sssp'])) { + if (in_array($display->clientType, ['android', 'lg', 'sssp', 'chromeOS'])) { $display->buttons[] = array( 'id' => 'display_button_checkLicence', 'url' => $this->urlFor($request, 'display.licencecheck.form', ['id' => $display->displayId]), diff --git a/lib/Controller/DisplayProfile.php b/lib/Controller/DisplayProfile.php index 3d27d9a93a..941bc21c2c 100644 --- a/lib/Controller/DisplayProfile.php +++ b/lib/Controller/DisplayProfile.php @@ -347,7 +347,10 @@ public function editForm(Request $request, Response $response, $id) } // Player Version Setting - $versionId = $displayProfile->getSetting('versionMediaId'); + $versionId = $displayProfile->type === 'chromeOS' + ? $displayProfile->getSetting('playerVersionId') + : $displayProfile->getSetting('versionMediaId'); + $playerVersions = []; // Daypart - Operating Hours @@ -358,8 +361,9 @@ public function editForm(Request $request, Response $response, $id) if ($versionId !== null) { try { $playerVersions[] = $this->playerVersionFactory->getById($versionId); - } catch (NotFoundException $e) { - $this->getLog()->debug('Unknown versionId set on Display Profile. ' . $displayProfile->displayProfileId); + } catch (NotFoundException) { + $this->getLog()->debug('Unknown versionId set on Display Profile. ' + . $displayProfile->displayProfileId); } } @@ -456,12 +460,14 @@ public function edit(Request $request, Response $response, $id) $displayProfile->name = $parsedParams->getString('name'); $displayProfile->isDefault = $parsedParams->getCheckbox('isDefault'); + // Track changes to versionMediaId + $originalPlayerVersionId = $displayProfile->getSetting('playerVersionId'); + // Different fields for each client type $this->editConfigFields($displayProfile, $parsedParams); // Capture and update commands foreach ($this->commandFactory->query() as $command) { - /* @var \Xibo\Entity\Command $command */ if ($parsedParams->getString('commandString_' . $command->commandId) != null) { // Set and assign the command $command->commandString = $parsedParams->getString('commandString_' . $command->commandId); @@ -474,6 +480,22 @@ public function edit(Request $request, Response $response, $id) } } + // If we are chromeOS and the default profile, has the player version changed? + if ($displayProfile->type === 'chromeOS' + && ($displayProfile->isDefault || $displayProfile->hasPropertyChanged('isDefault')) + && ($originalPlayerVersionId !== $displayProfile->getSetting('playerVersionId')) + ) { + $this->getLog()->debug('edit: updating symlink to the latest chromeOS version'); + + // Update a symlink to the new player version. + try { + $version = $this->playerVersionFactory->getById($displayProfile->getSetting('playerVersionId')); + $version->setActive(); + } catch (NotFoundException) { + $this->getLog()->error('edit: Player version does not exist'); + } + } + // Save the changes $displayProfile->save(); diff --git a/lib/Controller/DisplayProfileConfigFields.php b/lib/Controller/DisplayProfileConfigFields.php index d59a40bb74..a0fae05fa3 100644 --- a/lib/Controller/DisplayProfileConfigFields.php +++ b/lib/Controller/DisplayProfileConfigFields.php @@ -892,6 +892,71 @@ public function editConfigFields($displayProfile, $sanitizedParams, $config = nu break; + case 'chromeOS': + if ($sanitizedParams->hasParam('licenceCode')) { + $this->handleChangedSettings('licenceCode', ($ownConfig) ? $displayProfile->getSetting('licenceCode') : $display->getSetting('licenceCode'), $sanitizedParams->getString('licenceCode'), $changedSettings); + $displayProfile->setSetting('licenceCode', $sanitizedParams->getString('licenceCode'), $ownConfig, $config); + } + + if ($sanitizedParams->hasParam('collectInterval')) { + $this->handleChangedSettings('collectInterval', ($ownConfig) ? $displayProfile->getSetting('collectInterval') : $display->getSetting('collectInterval'), $sanitizedParams->getInt('collectInterval'), $changedSettings); + $displayProfile->setSetting('collectInterval', $sanitizedParams->getInt('collectInterval'), $ownConfig, $config); + } + + if ($sanitizedParams->hasParam('dayPartId')) { + $this->handleChangedSettings('dayPartId', ($ownConfig) ? $displayProfile->getSetting('dayPartId') : $display->getSetting('dayPartId'), $sanitizedParams->getInt('dayPartId'), $changedSettings); + $displayProfile->setSetting('dayPartId', $sanitizedParams->getInt('dayPartId'), $ownConfig, $config); + } + + if ($sanitizedParams->hasParam('xmrNetworkAddress')) { + $this->handleChangedSettings('xmrNetworkAddress',($ownConfig) ? $displayProfile->getSetting('xmrNetworkAddress') : $display->getSetting('xmrNetworkAddress'), $sanitizedParams->getString('xmrNetworkAddress'), $changedSettings); + $displayProfile->setSetting('xmrNetworkAddress', $sanitizedParams->getString('xmrNetworkAddress'), $ownConfig, $config); + } + + if ($sanitizedParams->hasParam('statsEnabled')) { + $this->handleChangedSettings('statsEnabled', ($ownConfig) ? $displayProfile->getSetting('statsEnabled') : $display->getSetting('statsEnabled'), $sanitizedParams->getCheckbox('statsEnabled'), $changedSettings); + $displayProfile->setSetting('statsEnabled', $sanitizedParams->getCheckbox('statsEnabled'), $ownConfig, $config); + } + + if ($sanitizedParams->hasParam('aggregationLevel')) { + $this->handleChangedSettings('aggregationLevel', ($ownConfig) ? $displayProfile->getSetting('aggregationLevel') : $display->getSetting('aggregationLevel'), $sanitizedParams->getString('aggregationLevel'), $changedSettings); + $displayProfile->setSetting('aggregationLevel', $sanitizedParams->getString('aggregationLevel'), $ownConfig, $config); + } + + if ($sanitizedParams->hasParam('logLevel')) { + $this->handleChangedSettings('logLevel', ($ownConfig) ? $displayProfile->getSetting('logLevel') : $display->getSetting('logLevel'), $sanitizedParams->getString('logLevel'), $changedSettings); + $displayProfile->setSetting('logLevel', $sanitizedParams->getString('logLevel'), $ownConfig, $config); + } + + if ($sanitizedParams->hasParam('elevateLogsUntil')) { + $this->handleChangedSettings( + 'elevateLogsUntil', + ($ownConfig) + ? $displayProfile->getSetting('elevateLogsUntil') + : $display->getSetting('elevateLogsUntil'), + $sanitizedParams->getDate('elevateLogsUntil')?->format('U'), + $changedSettings + ); + $displayProfile->setSetting( + 'elevateLogsUntil', + $sanitizedParams->getDate('elevateLogsUntil')?->format('U'), + $ownConfig, + $config + ); + } + + if ($sanitizedParams->hasParam('playerVersionId')) { + $this->handleChangedSettings('playerVersionId', ($ownConfig) ? $displayProfile->getSetting('playerVersionId') : $display->getSetting('playerVersionId'), $sanitizedParams->getInt('playerVersionId'), $changedSettings); + $displayProfile->setSetting('playerVersionId', $sanitizedParams->getInt('playerVersionId'), $ownConfig, $config); + } + + if ($sanitizedParams->hasParam('screenShotSize')) { + $this->handleChangedSettings('screenShotSize', ($ownConfig) ? $displayProfile->getSetting('screenShotSize') : $display->getSetting('screenShotSize'), $sanitizedParams->getInt('screenShotSize'), $changedSettings); + $displayProfile->setSetting('screenShotSize', $sanitizedParams->getInt('screenShotSize'), $ownConfig, $config); + } + + break; + default: if ($displayProfile->isCustom()) { $this->getLog()->info('Edit for custom Display profile type ' . $displayProfile->getClientType()); diff --git a/lib/Controller/PlayerSoftware.php b/lib/Controller/PlayerSoftware.php index 2e33712787..11ef2dde01 100644 --- a/lib/Controller/PlayerSoftware.php +++ b/lib/Controller/PlayerSoftware.php @@ -258,7 +258,7 @@ public function delete(Request $request, Response $response, $id) // Unset player version from Display Profile $displayProfiles = $this->displayProfileFactory->query(); - foreach($displayProfiles as $displayProfile) { + foreach ($displayProfiles as $displayProfile) { if (in_array($displayProfile->type, ['android', 'lg', 'sssp'])) { $currentVersionId = $displayProfile->getSetting('versionMediaId'); @@ -266,6 +266,13 @@ public function delete(Request $request, Response $response, $id) $displayProfile->setSetting('versionMediaId', null); $displayProfile->save(); } + } else if ($displayProfile->type === 'chromeOS') { + $currentVersionId = $displayProfile->getSetting('playerVersionId'); + + if ($currentVersionId === $version->versionId) { + $displayProfile->setSetting('playerVersionId', null); + $displayProfile->save(); + } } } @@ -630,6 +637,9 @@ public function add(Request $request, Response $response) // everything is fine, move the file from temp folder. rename($filePath, $libraryFolder . 'playersoftware/' . $playerSoftware->fileName); + // Unpack if necessary + $playerSoftware->unpack($libraryFolder); + // return $file->id = $playerSoftware->versionId; $file->md5 = $playerSoftware->md5; @@ -737,6 +747,6 @@ private function outputSsspXml($version, $size) */ private function getValidExtensions() { - return ['apk','ipk','wgt']; + return ['apk', 'ipk', 'wgt', 'chrome']; } } diff --git a/lib/Controller/Pwa.php b/lib/Controller/Pwa.php new file mode 100644 index 0000000000..1993069860 --- /dev/null +++ b/lib/Controller/Pwa.php @@ -0,0 +1,253 @@ +. + */ + +namespace Xibo\Controller; + +use Psr\Container\ContainerInterface; +use Slim\Http\Response as Response; +use Slim\Http\ServerRequest as Request; +use Xibo\Factory\DisplayFactory; +use Xibo\Factory\DisplayProfileFactory; +use Xibo\Factory\PlayerVersionFactory; +use Xibo\Support\Exception\AccessDeniedException; +use Xibo\Support\Exception\GeneralException; +use Xibo\Support\Exception\InvalidArgumentException; +use Xibo\Xmds\Soap7; + +class Pwa extends Base +{ + public function __construct( + private readonly DisplayFactory $displayFactory, + private readonly DisplayProfileFactory $displayProfileFactory, + private readonly PlayerVersionFactory $playerVersionFactory, + private readonly ContainerInterface $container + ) { + } + + /** + * @throws \Xibo\Support\Exception\NotFoundException + * @throws \Xibo\Support\Exception\AccessDeniedException + */ + public function home(Request $request, Response $response): Response + { + $params = $this->getSanitizer($request->getParams()); + + // See if we have a specific display profile we want to use. + $displayProfileId = $params->getInt('displayProfileId'); + if (!empty($displayProfileId)) { + $displayProfile = $this->displayProfileFactory->getById($displayProfileId); + + if ($displayProfile->type !== 'chromeOS') { + throw new AccessDeniedException(__('This type of display is not allowed to access this API')); + } + } else { + $displayProfile = $this->displayProfileFactory->getDefaultByType('chromeOS'); + } + + // We have the display profile. + // use that to get the player version + $versionId = $displayProfile->getSetting('versionMediaId'); + if (!empty($versionId)) { + $version = $this->playerVersionFactory->getById($versionId); + } else { + $version = $this->playerVersionFactory->getByType('chromeOS'); + } + + // Output the index.html file from the relevant bundle. + $response->getBody()->write(''); + return $response; + } + + /** + * @throws \Xibo\Support\Exception\InvalidArgumentException + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \Xibo\Support\Exception\NotFoundException + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Xibo\Support\Exception\AccessDeniedException + * @throws \Xibo\Support\Exception\GeneralException + */ + public function getResource(Request $request, Response $response): Response + { + // Create a Soap client and call it. + $params = $this->getSanitizer($request->getParams()); + + try { + // Which version are we? + $version = $params->getInt('v', [ + 'default' => 7, + 'throw' => function () { + throw new InvalidArgumentException(__('Missing Version'), 'v'); + } + ]); + + if ($version < 7) { + throw new InvalidArgumentException(__('PWA supported from XMDS schema 7 onward.'), 'v'); + } + + // Validate that this display should call this service. + $hardwareKey = $params->getString('hardwareKey'); + $display = $this->displayFactory->getByLicence($hardwareKey); + if (!$display->isPwa()) { + throw new AccessDeniedException(__('Please use XMDS API'), 'hardwareKey'); + } + + // Check it is still authorised. + if ($display->licensed == 0) { + throw new AccessDeniedException(__('Display unauthorised')); + } + + /** @var Soap7 $soap */ + $soap = $this->getSoap($version); + + $this->getLog()->debug('getResource: passing to Soap class'); + + $body = $soap->GetResource( + $params->getString('serverKey'), + $params->getString('hardwareKey'), + $params->getInt('layoutId'), + $params->getInt('regionId') . '', + $params->getInt('mediaId') . '', + ); + + $response->getBody()->write($body); + + return $response + ->withoutHeader('Content-Security-Policy') + ->withHeader('Access-Control-Allow-Origin', '*') + ->withHeader('Access-Control-Allow-Methods', '*') + ->withHeader( + 'Access-Control-Allow-Headers', + 'append,delete,entries,foreach,get,has,keys,set,values,Origin,Authorization' + ); + } catch (\SoapFault $e) { + throw new GeneralException($e->getMessage()); + } + } + + /** + * @throws \Xibo\Support\Exception\InvalidArgumentException + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Xibo\Support\Exception\NotFoundException + * @throws \Xibo\Support\Exception\GeneralException + */ + public function getData(Request $request, Response $response): Response + { + $params = $this->getSanitizer($request->getParams()); + + try { + $version = $params->getInt('v', [ + 'default' => 7, + 'throw' => function () { + throw new InvalidArgumentException(__('Missing Version'), 'v'); + } + ]); + + if ($version < 7) { + throw new InvalidArgumentException(__('PWA supported from XMDS schema 7 onward.'), 'v'); + } + + // Validate that this display should call this service. + $hardwareKey = $params->getString('hardwareKey'); + $display = $this->displayFactory->getByLicence($hardwareKey); + if (!$display->isPwa()) { + throw new AccessDeniedException(__('Please use XMDS API'), 'hardwareKey'); + } + + // Check it is still authorised. + if ($display->licensed == 0) { + throw new AccessDeniedException(__('Display unauthorised')); + } + + /** @var Soap7 $soap */ + $soap = $this->getSoap($version); + $body = $soap->GetData( + $params->getString('serverKey'), + $params->getString('hardwareKey'), + $params->getInt('widgetId'), + ); + + $response->getBody()->write($body); + + return $response + ->withoutHeader('Content-Security-Policy') + ->withHeader('Access-Control-Allow-Origin', '*') + ->withHeader('Access-Control-Allow-Methods', '*') + ->withHeader( + 'Access-Control-Allow-Headers', + 'append,delete,entries,foreach,get,has,keys,set,values,Origin,Authorization' + ); + } catch (\SoapFault $e) { + throw new GeneralException($e->getMessage()); + } + } + + /** + * @throws \Xibo\Support\Exception\InvalidArgumentException + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + private function getSoap(int $version): mixed + { + $class = '\Xibo\Xmds\Soap' . $version; + if (!class_exists($class)) { + throw new InvalidArgumentException(__('Unknown version'), 'version'); + } + + // Overwrite the logger + $uidProcessor = new \Monolog\Processor\UidProcessor(7); + $logProcessor = new \Xibo\Xmds\LogProcessor( + $this->container->get('logger'), + $uidProcessor->getUid() + ); + $this->container->get('logger')->pushProcessor($logProcessor); + + return new $class( + $logProcessor, + $this->container->get('pool'), + $this->container->get('store'), + $this->container->get('timeSeriesStore'), + $this->container->get('logService'), + $this->container->get('sanitizerService'), + $this->container->get('configService'), + $this->container->get('requiredFileFactory'), + $this->container->get('moduleFactory'), + $this->container->get('layoutFactory'), + $this->container->get('dataSetFactory'), + $this->displayFactory, + $this->container->get('userGroupFactory'), + $this->container->get('bandwidthFactory'), + $this->container->get('mediaFactory'), + $this->container->get('widgetFactory'), + $this->container->get('regionFactory'), + $this->container->get('notificationFactory'), + $this->container->get('displayEventFactory'), + $this->container->get('scheduleFactory'), + $this->container->get('dayPartFactory'), + $this->container->get('playerVersionFactory'), + $this->container->get('dispatcher'), + $this->container->get('campaignFactory'), + $this->container->get('syncGroupFactory'), + $this->container->get('playerFaultFactory') + ); + } +} diff --git a/lib/Dependencies/Controllers.php b/lib/Dependencies/Controllers.php index b6fe8a2157..81ec8b9510 100644 --- a/lib/Dependencies/Controllers.php +++ b/lib/Dependencies/Controllers.php @@ -415,6 +415,16 @@ public static function registerControllersWithDi() $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService')); return $controller; }, + '\Xibo\Controller\Pwa' => function (ContainerInterface $c) { + $controller = new \Xibo\Controller\Pwa( + $c->get('displayFactory'), + $c->get('displayProfileFactory'), + $c->get('playerVersionFactory'), + $c, + ); + $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService')); + return $controller; + }, '\Xibo\Controller\Region' => function (ContainerInterface $c) { $controller = new \Xibo\Controller\Region( $c->get('regionFactory'), diff --git a/lib/Entity/Display.php b/lib/Entity/Display.php index c96a7ebcd0..2cddb3358e 100644 --- a/lib/Entity/Display.php +++ b/lib/Entity/Display.php @@ -691,6 +691,14 @@ public function getLanguages() return empty($this->languages) ? [] : explode(',', $this->languages); } + /** + * @return bool true is this display is a PWA + */ + public function isPwa(): bool + { + return $this->clientType === 'chromeOS'; + } + /** * Is this display auditing? * return bool diff --git a/lib/Entity/PlayerVersion.php b/lib/Entity/PlayerVersion.php index e7aaf9f965..5039bc502c 100644 --- a/lib/Entity/PlayerVersion.php +++ b/lib/Entity/PlayerVersion.php @@ -1,6 +1,6 @@ config->getSetting('LIBRARY_LOCATION'); // delete file - if (file_exists($libraryLocation . 'playersoftware/'. $this->fileName)) { - unlink($libraryLocation . 'playersoftware/'. $this->fileName); + if (file_exists($libraryLocation . 'playersoftware/' . $this->fileName)) { + unlink($libraryLocation . 'playersoftware/' . $this->fileName); + } + + // delete unpacked file + if (is_dir($libraryLocation . 'playersoftware/chromeos/' . $this->versionId)) { + (new Filesystem())->remove($libraryLocation . 'playersoftware/chromeos/' . $this->versionId); } } + /** + * @throws \Xibo\Support\Exception\InvalidArgumentException + */ + public function unpack(string $libraryFolder): static + { + // ChromeOS + // Unpack the `.chrome` file as a tar/gz, validate its signature, extract it into the library folder + if ($this->type === 'chromeOS') { + $this->getLog()->debug('add: handling chromeOS upload'); + + // TODO: check the signature of the file to make sure it comes from a verified source. + + $zip = new \ZipArchive(); + if (!$zip->open($libraryFolder . 'playersoftware/' . $this->fileName)) { + throw new InvalidArgumentException(__('Unable to open ZIP')); + } + + // Make sure the ZIP file contains a manifest.json file. + if ($zip->locateName('manifest.json') === false) { + throw new InvalidArgumentException(__('Software package does not contain a manifest')); + } + + // Make a folder for this + $folder = $libraryFolder . 'playersoftware/chromeos/' . $this->versionId; + if (is_dir($folder)) { + unlink($folder); + } + mkdir($folder); + + // Extract to that folder + $zip->extractTo($folder); + $zip->close(); + + // Update manifest.json + $manifest = file_get_contents($folder . '/manifest.json'); + + $isXiboThemed = $this->config->getThemeConfig('app_name', 'Xibo') === 'Xibo'; + if (!$isXiboThemed) { + $manifest = Str::replace( + 'Xibo Digital Signage', + $this->config->getThemeConfig('theme_title'), + $manifest + ); + } + + // Update asset URLs + $logoUrl = $this->config->uri('img/xibologo.png'); + + $manifest = Str::replace( + [ + 'assets/icons/512x512.png', + 'assets/icons/192x192.png', + 'assets/icons/48x48.png', + 'assets/icons/24x24.png', + ], + $logoUrl, + $manifest + ); + + file_put_contents($folder . '/manifest.json', $manifest); + } + + return $this; + } + + public function setActive(): static + { + if ($this->type === 'chromeOS') { + $this->getLog()->debug('setActive: set this version to be the latest'); + + $chromeLocation = $this->config->getSetting('LIBRARY_LOCATION') . 'playersoftware/chromeos'; + if (is_link($chromeLocation . '/latest')) { + unlink($chromeLocation . '/latest'); + } + symlink($chromeLocation . '/' . $this->versionId, $chromeLocation . '/latest'); + } + + return $this; + } + /** * Load */ @@ -256,7 +344,10 @@ public function validate() { } } - public function decorateRecord() + /** + * @return $this + */ + public function decorateRecord(): static { $version = ''; $code = null; @@ -267,41 +358,41 @@ public function decorateRecord() // standard releases if (count($explode) === 5) { - if (strpos($explode[4], '.') !== false) { + if (str_contains($explode[4], '.')) { $explodeExtension = explode('.', $explode[4]); $explode[4] = $explodeExtension[0]; } - if (strpos($explode[3], 'v') !== false) { + if (str_contains($explode[3], 'v')) { $version = strtolower(substr(strrchr($explode[3], 'v'), 1, 3)) ; } - if (strpos($explode[4], 'R') !== false) { + if (str_contains($explode[4], 'R')) { $code = strtolower(substr(strrchr($explode[4], 'R'), 1, 3)) ; } $playerShowVersion = $version . ' Revision ' . $code; // for DSDevices specific apk } elseif (count($explode) === 6) { - if (strpos($explode[5], '.') !== false) { + if (str_contains($explode[5], '.')) { $explodeExtension = explode('.', $explode[5]); $explode[5] = $explodeExtension[0]; } - if (strpos($explode[3], 'v') !== false) { + if (str_contains($explode[3], 'v')) { $version = strtolower(substr(strrchr($explode[3], 'v'), 1, 3)) ; } - if (strpos($explode[4], 'R') !== false) { + if (str_contains($explode[4], 'R')) { $code = strtolower(substr(strrchr($explode[4], 'R'), 1, 3)) ; } $playerShowVersion = $version . ' Revision ' . $code . ' ' . $explode[5]; // for white labels } elseif (count($explode) === 3) { - if (strpos($explode[2], '.') !== false) { + if (str_contains($explode[2], '.')) { $explodeExtension = explode('.', $explode[2]); $explode[2] = $explodeExtension[0]; } - if (strpos($explode[1], 'v') !== false) { + if (str_contains($explode[1], 'v')) { $version = strtolower(substr(strrchr($explode[1], 'v'), 1, 3)) ; } - if (strpos($explode[2], 'R') !== false) { + if (str_contains($explode[2], 'R')) { $code = strtolower(substr(strrchr($explode[2], 'R'), 1, 3)) ; } $playerShowVersion = $version . ' Revision ' . $code . ' ' . $explode[0]; @@ -311,10 +402,12 @@ public function decorateRecord() if ($extension == 'apk') { $type = 'android'; - } elseif ($extension == 'ipk') { + } else if ($extension == 'ipk') { $type = 'lg'; - } elseif ($extension == 'wgt') { + } else if ($extension == 'wgt') { $type = 'sssp'; + } else if ($extension == 'chrome') { + $type = 'chromeOS'; } $this->version = $version; diff --git a/lib/Factory/DisplayProfileFactory.php b/lib/Factory/DisplayProfileFactory.php index 46bcf2b7bf..d912dee6e0 100644 --- a/lib/Factory/DisplayProfileFactory.php +++ b/lib/Factory/DisplayProfileFactory.php @@ -98,16 +98,15 @@ public function getById($displayProfileId) * @return DisplayProfile * @throws NotFoundException */ - public function getDefaultByType($type) + public function getDefaultByType(string $type): DisplayProfile { $profiles = $this->query(null, ['disableUserCheck' => 1, 'type' => $type, 'isDefault' => 1]); if (count($profiles) <= 0) { - throw new NotFoundException(); + throw new NotFoundException(sprintf(__('No default display profile for %s'), $type)); } $profile = $profiles[0]; - /* @var DisplayProfile $profile */ $profile->load(); return $profile; } @@ -380,6 +379,30 @@ public function loadForType($type) ['name' => 'updateEndWindow', 'default' => '00:00'], ['name' => 'embeddedServerAllowWan', 'default' => 0, 'type' => 'checkbox'], ['name' => 'serverPort', 'default' => 9696], + ], + 'chromeOS' => [ + ['name' => 'licenceCode', 'default' => ''], + ['name' => 'collectInterval', 'default' => 300], + ['name' => 'xmrNetworkAddress', 'default' => ''], + [ + 'name' => 'statsEnabled', + 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0), + 'type' => 'checkbox', + ], + [ + 'name' => 'aggregationLevel', + 'default' => $this->config->getSetting('DISPLAY_PROFILE_AGGREGATION_LEVEL_DEFAULT'), + 'type' => 'string', + ], + ['name' => 'playerVersionId', 'default' => null], + ['name' => 'dayPartId', 'default' => null], + ['name' => 'logLevel', 'default' => 'error'], + ['name' => 'elevateLogsUntil', 'default' => 0, 'type' => 'int'], + ['name' => 'screenShotRequestInterval', 'default' => 0, 'type' => 'int'], + [ + 'name' => 'screenShotSize', + 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_SCREENSHOT_SIZE_DEFAULT', 200), + ], ] ]; diff --git a/lib/Factory/PlayerVersionFactory.php b/lib/Factory/PlayerVersionFactory.php index 9578d53a44..8bc5d99874 100644 --- a/lib/Factory/PlayerVersionFactory.php +++ b/lib/Factory/PlayerVersionFactory.php @@ -1,6 +1,6 @@ query(null, array('disableUserCheck' => 1, 'playerType' => $type)); - if (count($versions) <= 0) + if (count($versions) <= 0) { throw new NotFoundException(__('Cannot find Player Version')); + } return $versions[0]; } diff --git a/lib/Helper/LinkSigner.php b/lib/Helper/LinkSigner.php index 96902c8c47..eba1cb22ec 100644 --- a/lib/Helper/LinkSigner.php +++ b/lib/Helper/LinkSigner.php @@ -1,6 +1,6 @@ getUrl() . '/xmds.php'; + $saveAsPath = $xmdsRoot + . '?file=' . $storedAs + . '&displayId=' . $display->displayId + . '&type=' . $type + . '&itemId=' . $itemId; + + if ($fileType !== null) { + $saveAsPath .= '&fileType=' . $fileType; + } + + $saveAsPath .= '&' . LinkSigner::getSignature( + parse_url($xmdsRoot, PHP_URL_HOST), + $storedAs, + time() + ($display->getSetting('collectionInterval', 300) * 2), + $encryptionKey, + ); + + // CDN? + if (!empty($cdnUrl)) { + // Serve a link to the CDN + // CDN_URL has a `?dl=` parameter on the end already, so we just encode our string and concatenate it + return 'http' . ( + ( + (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) == 'on') || + (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) + && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) == 'https') + ) ? 's' : '') + . '://' . $cdnUrl . urlencode($saveAsPath); + } else { + // Serve a HTTP link to XMDS + return $saveAsPath; + } + } + /** * Get a S3 compatible signature */ diff --git a/lib/Middleware/Csp.php b/lib/Middleware/Csp.php index e46bb514a0..f4a8825830 100644 --- a/lib/Middleware/Csp.php +++ b/lib/Middleware/Csp.php @@ -67,6 +67,8 @@ public function process(Request $request, RequestHandler $handler): Response $response = $handler->handle($request); // Add our header - return $response->withAddedHeader('Content-Security-Policy', $csp); + return $response + ->withAddedHeader('X-Frame-Options', 'SAMEORIGIN') + ->withAddedHeader('Content-Security-Policy', $csp); } } diff --git a/lib/Middleware/CustomMiddlewareTrait.php b/lib/Middleware/CustomMiddlewareTrait.php index 4dd9112b94..5794b01acc 100644 --- a/lib/Middleware/CustomMiddlewareTrait.php +++ b/lib/Middleware/CustomMiddlewareTrait.php @@ -1,6 +1,6 @@ app->getContainer(); } - /*** + /** * @param $key * @return mixed + * @throws \DI\DependencyException + * @throws \DI\NotFoundException + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface */ - protected function getFromContainer($key) + protected function getFromContainer($key): mixed { return $this->getContainer()->get($key); } + + /** + * Append public routes + * @param \Psr\Http\Message\ServerRequestInterface $request + * @param array $routes + * @return \Psr\Http\Message\ServerRequestInterface + */ + protected function appendPublicRoutes(ServerRequestInterface $request, array $routes): ServerRequestInterface + { + // Set some public routes + return $request->withAttribute( + 'publicRoutes', + array_merge($request->getAttribute('publicRoutes', []), $routes) + ); + } } diff --git a/lib/Service/MediaService.php b/lib/Service/MediaService.php index d3d6c9cb88..f0f0d8f58e 100644 --- a/lib/Service/MediaService.php +++ b/lib/Service/MediaService.php @@ -1,6 +1,6 @@ $value) { if (is_string($value)) { - $data[$row][$key] = $this->decorateMediaForPlayer($storedAs, $value); + $data[$row][$key] = $this->decorateMediaForPlayer($display, $encryptionKey, $storedAs, $value); } } } else if (is_object($item)) { foreach (ObjectVars::getObjectVars($item) as $key => $value) { if (is_string($value)) { - $item->{$key} = $this->decorateMediaForPlayer($storedAs, $value); + $item->{$key} = $this->decorateMediaForPlayer($display, $encryptionKey, $storedAs, $value); } } } @@ -352,16 +359,26 @@ public function decorateForPlayer( } /** + * @param \Xibo\Entity\Display $display + * @param string $encryptionKey * @param array $storedAs * @param string|null $data * @return string|null + * @throws \Xibo\Support\Exception\NotFoundException */ - private function decorateMediaForPlayer(array $storedAs, ?string $data): ?string - { + private function decorateMediaForPlayer( + Display $display, + string $encryptionKey, + array $storedAs, + ?string $data, + ): ?string { if ($data === null) { return null; } + // Do we need to add a URL prefix to the requests? + $prefix = $display->isPwa() ? '/pwa/' : ''; + // Media substitutes $matches = []; preg_match_all('/\[\[(.*?)\]\]/', $data, $matches); @@ -369,7 +386,19 @@ private function decorateMediaForPlayer(array $storedAs, ?string $data): ?string if (Str::startsWith($match, 'mediaId')) { $value = explode('=', $match); if (array_key_exists($value[1], $storedAs)) { - $data = str_replace('[[' . $match . ']]', $storedAs[$value[1]], $data); + if ($display->isPwa()) { + $url = LinkSigner::generateSignedLink( + $display, + $encryptionKey, + null, + 'M', + $value[1], + $storedAs[$value[1]] + ); + } else { + $url = $storedAs[$value[1]]; + } + $data = str_replace('[[' . $match . ']]', $prefix . $url, $data); } else { $data = str_replace('[[' . $match . ']]', '', $data); } diff --git a/lib/Widget/Render/WidgetHtmlRenderer.php b/lib/Widget/Render/WidgetHtmlRenderer.php index 5492a3ee60..a83f74ce4a 100644 --- a/lib/Widget/Render/WidgetHtmlRenderer.php +++ b/lib/Widget/Render/WidgetHtmlRenderer.php @@ -29,12 +29,14 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Slim\Views\Twig; +use Xibo\Entity\Display; use Xibo\Entity\Module; use Xibo\Entity\ModuleTemplate; use Xibo\Entity\Region; use Xibo\Entity\Widget; use Xibo\Factory\ModuleFactory; use Xibo\Helper\DateFormatHelper; +use Xibo\Helper\LinkSigner; use Xibo\Helper\Translate; use Xibo\Service\ConfigServiceInterface; use Xibo\Support\Exception\InvalidArgumentException; @@ -341,58 +343,176 @@ public function decorateForPreview( /** * Decorate the HTML output for a player + * @param \Xibo\Entity\Display $display * @param string $output * @param array $storedAs A keyed array of library media this widget has access to * @param bool $isSupportsDataUrl * @param array $data A keyed array of data this widget has access to * @param \Xibo\Widget\Definition\Asset[] $assets A keyed array of assets this widget has access to * @return string + * @throws \Xibo\Support\Exception\NotFoundException */ public function decorateForPlayer( + Display $display, string $output, array $storedAs, bool $isSupportsDataUrl = true, array $data = [], array $assets = [] ): string { + // Do we need to add a URL prefix to the requests? + $auth = $display->isPwa() + ? '&v=7&serverKey=' . $this->config->getSetting('serverKey') . '&hardwareKey=' . $display->license + : null; + $encryptionKey = $this->config->getApiKeyDetails()['encryptionKey']; + $matches = []; preg_match_all('/\[\[(.*?)\]\]/', $output, $matches); foreach ($matches[1] as $match) { if ($match === 'PlayerBundle') { - $output = str_replace('[[PlayerBundle]]', 'bundle.min.js', $output); + if ($display->isPwa()) { + $url = LinkSigner::generateSignedLink( + $display, + $encryptionKey, + null, + 'P', + 1, + 'bundle.min.js', + 'bundle', + ); + } else { + $url = 'bundle.min.js'; + } + $output = str_replace( + '[[PlayerBundle]]', + $url, + $output, + ); } else if ($match === 'FontBundle') { - $output = str_replace('[[FontBundle]]', 'fonts.css', $output); + if ($display->isPwa()) { + $url = LinkSigner::generateSignedLink( + $display, + $encryptionKey, + null, + 'P', + 1, + 'fonts.css', + 'fontCss', + ); + } else { + $url = 'fonts.css'; + } + $output = str_replace( + '[[FontBundle]]', + $url, + $output, + ); } else if ($match === 'ViewPortWidth') { - // Player does this itself. - continue; + if ($display->isPwa()) { + $output = str_replace( + '[[ViewPortWidth]]', + explode('x', ($display->resolution ?: 'x'))[0], + $output, + ); + } } else if (Str::startsWith($match, 'dataUrl')) { $value = explode('=', $match); - $replace = $isSupportsDataUrl ? $value[1] . '.json' : 'null'; - $output = str_replace('[[' . $match . ']]', $replace, $output); + $output = str_replace( + '[[' . $match . ']]', + $isSupportsDataUrl + ? ($display->isPwa() + ? '/pwa/getData?widgetId=' . $value[1] . $auth + : '/' . $value[1] . '.json') + : 'null', + $output, + ); } else if (Str::startsWith($match, 'data=')) { $value = explode('=', $match); - $output = str_replace('"[[' . $match . ']]"', isset($data[$value[1]]) - ? json_encode($data[$value[1]]) - : 'null', $output); + $output = str_replace( + '"[[' . $match . ']]"', + isset($data[$value[1]]) + ? json_encode($data[$value[1]]) + : 'null', + $output, + ); } else if (Str::startsWith($match, 'mediaId') || Str::startsWith($match, 'libraryId')) { $value = explode('=', $match); if (array_key_exists($value[1], $storedAs)) { - $output = str_replace('[[' . $match . ']]', $storedAs[$value[1]], $output); + if ($display->isPwa()) { + $url = LinkSigner::generateSignedLink( + $display, + $encryptionKey, + null, + 'M', + $value[1], + $storedAs[$value[1]] + ); + } else { + $url = $storedAs[$value[1]]; + } + $output = str_replace( + '[[' . $match . ']]', + $url, + $output, + ); } else { - $output = str_replace('[[' . $match . ']]', '', $output); + $output = str_replace( + '[[' . $match . ']]', + '', + $output, + ); } } else if (Str::startsWith($match, 'assetId')) { $value = explode('=', $match); if (array_key_exists($value[1], $assets)) { - $output = str_replace('[[' . $match . ']]', $assets[$value[1]]->getFilename(), $output); + $asset = $assets[$value[1]]; + if ($display->isPwa()) { + $url = LinkSigner::generateSignedLink( + $display, + $encryptionKey, + null, + 'P', + $asset->id, + $asset->getFilename(), + 'asset', + ); + } else { + $url = $asset->getFilename(); + } + $output = str_replace( + '[[' . $match . ']]', + $url, + $output, + ); } else { - $output = str_replace('[[' . $match . ']]', '', $output); + $output = str_replace( + '[[' . $match . ']]', + '', + $output, + ); } } else if (Str::startsWith($match, 'assetAlias')) { $value = explode('=', $match); foreach ($assets as $asset) { if ($asset->alias === $value[1]) { - $output = str_replace('[[' . $match . ']]', $asset->getFilename(), $output); + if ($display->isPwa()) { + $url = LinkSigner::generateSignedLink( + $display, + $encryptionKey, + null, + 'P', + $asset->id, + $asset->getFilename(), + 'asset', + ); + } else { + $url = $asset->getFilename(); + } + $output = str_replace( + '[[' . $match . ']]', + $url, + $output, + ); continue 2; } } diff --git a/lib/Xmds/Soap.php b/lib/Xmds/Soap.php index 8c1f843abe..dcc7f0cf0c 100644 --- a/lib/Xmds/Soap.php +++ b/lib/Xmds/Soap.php @@ -111,7 +111,7 @@ class Soap private $sanitizerService; /** @var ConfigServiceInterface */ - private $configService; + protected $configService; /** @var RequiredFileFactory */ protected $requiredFileFactory; @@ -412,7 +412,10 @@ protected function doRequiredFiles( } // Generate a new URL. - $newUrl = $this->generateRequiredFileDownloadPath( + $newUrl = LinkSigner::generateSignedLink( + $this->display, + $this->configService->getApiKeyDetails()['encryptionKey'], + null, $type, $realId, $node->getAttribute('saveAs'), @@ -762,7 +765,14 @@ protected function doRequiredFiles( if ($httpDownloads) { // Serve a link instead (standard HTTP link) - $file->setAttribute('path', $this->generateRequiredFileDownloadPath('M', $id, $path)); + $file->setAttribute('path', LinkSigner::generateSignedLink( + $this->display, + $this->configService->getApiKeyDetails()['encryptionKey'], + null, + 'M', + $id, + $path, + )); $file->setAttribute('saveAs', $path); $file->setAttribute('download', 'http'); } else { @@ -843,7 +853,14 @@ protected function doRequiredFiles( if ($httpDownloads && $supportsHttpLayouts) { // Serve a link instead (standard HTTP link) - $file->setAttribute('path', $this->generateRequiredFileDownloadPath('L', $layoutId, $fileName)); + $file->setAttribute('path', LinkSigner::generateSignedLink( + $this->display, + $this->configService->getApiKeyDetails()['encryptionKey'], + null, + 'L', + $layoutId, + $fileName, + )); $file->setAttribute('saveAs', $fileName); $file->setAttribute('download', 'http'); } else { @@ -2505,7 +2522,12 @@ protected function doGetResource( throw new NotFoundException('Cache not ready'); } - $widgetData = $widgetDataProviderCache->decorateForPlayer($dataProvider->getData(), $media); + $widgetData = $widgetDataProviderCache->decorateForPlayer( + $this->display, + $this->configService->getApiKeyDetails()['encryptionKey'], + $dataProvider->getData(), + $media, + ); } catch (GeneralException $exception) { // No data cached yet, exception $this->getLog()->error('getResource: Failed to get data cache for widgetId ' @@ -2523,6 +2545,7 @@ protected function doGetResource( // Decorate for the player $resource = $renderer->decorateForPlayer( + $this->display, $resource, $media, $isSupportsDataUrl, @@ -2920,57 +2943,6 @@ protected function logBandwidth($displayId, $type, $sizeInBytes) $this->bandwidthFactory->createAndSave($type, $displayId, $sizeInBytes); } - /** - * Generate a file download path for HTTP downloads, taking into account the precence of a CDN. - * @param $type - * @param $itemId - * @param string $storedAs - * @param string|null $fileType - * @return string - * @throws \Xibo\Support\Exception\NotFoundException - */ - protected function generateRequiredFileDownloadPath( - $type, - $itemId, - string $storedAs, - string $fileType = null - ): string { - $xmdsRoot = Wsdl::getRoot(); - $saveAsPath = $xmdsRoot - . '?file=' . $storedAs - . '&displayId=' . $this->display->displayId - . '&type=' . $type - . '&itemId=' . $itemId; - - if ($fileType !== null) { - $saveAsPath .= '&fileType=' . $fileType; - } - - $saveAsPath .= '&' . LinkSigner::getSignature( - parse_url($xmdsRoot, PHP_URL_HOST), - $storedAs, - time() + ($this->display->getSetting('collectionInterval', 300) * 2), - $this->configService->getApiKeyDetails()['encryptionKey'], - ); - - // CDN? - $cdnUrl = $this->configService->getSetting('CDN_URL'); - if ($cdnUrl != '') { - // Serve a link to the CDN - // CDN_URL has a `?dl=` parameter on the end already, so we just encode our string and concatenate it - return 'http' . ( - ( - (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) == 'on') || - (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) - && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) == 'https') - ) ? 's' : '') - . '://' . $cdnUrl . urlencode($saveAsPath); - } else { - // Serve a HTTP link to XMDS - return $saveAsPath; - } - } - /** * Add a dependency to the provided DOM element * @param array $rfIds @@ -3020,7 +2992,10 @@ private function addDependency( // 3) some dependencies don't support HTTP downloads because they aren't in the library $httpFilePath = null; if ($httpDownloads && $dependency->isAvailableOverHttp) { - $httpFilePath = $this->generateRequiredFileDownloadPath( + $httpFilePath = LinkSigner::generateSignedLink( + $this->display, + $this->configService->getApiKeyDetails()['encryptionKey'], + null, RequiredFile::$TYPE_DEPENDENCY, $dependency->id, $dependencyBasePath, diff --git a/lib/Xmds/Soap7.php b/lib/Xmds/Soap7.php index 293f5a3896..4b64783013 100644 --- a/lib/Xmds/Soap7.php +++ b/lib/Xmds/Soap7.php @@ -1,6 +1,6 @@ intval($row['fileSize']), 'md5' => $row['md5'], 'saveAs' => $row['storedAs'], - 'path' => $this->generateRequiredFileDownloadPath( + 'path' => LinkSigner::generateSignedLink( + $this->display, + $this->configService->getApiKeyDetails()['encryptionKey'], + null, 'M', intval($row['mediaId']), $row['storedAs'], @@ -225,7 +229,12 @@ public function GetData($serverKey, $hardwareKey, $widgetId) } $resource = json_encode([ - 'data' => $widgetDataProviderCache->decorateForPlayer($dataProvider->getData(), $media), + 'data' => $widgetDataProviderCache->decorateForPlayer( + $this->display, + $this->configService->getApiKeyDetails()['encryptionKey'], + $dataProvider->getData(), + $media, + ), 'meta' => $dataProvider->getMeta(), 'files' => $requiredFiles, ]); diff --git a/views/display-form-edit.twig b/views/display-form-edit.twig index a7265565e3..af19a7ef8c 100644 --- a/views/display-form-edit.twig +++ b/views/display-form-edit.twig @@ -338,6 +338,8 @@ {% include "displayprofile-form-edit-linux.twig" %} {% elseif displayProfile.getClientType() == "lg" or displayProfile.getClientType() == "sssp" %} {% include "displayprofile-form-edit-soc.twig" %} + {% elseif displayProfile.getClientType() == "chromeOS" %} + {% include "displayprofile-form-edit-chromeos.twig" %} {% elseif displayProfile.isCustom() %} {{ include(displayProfile.getCustomEditTemplate()) }} {% endif %} diff --git a/views/displayprofile-form-edit-chromeos.twig b/views/displayprofile-form-edit-chromeos.twig new file mode 100644 index 0000000000..d8a36f912d --- /dev/null +++ b/views/displayprofile-form-edit-chromeos.twig @@ -0,0 +1,176 @@ +{# +/** + * Copyright (C) 2024 Xibo Signage Ltd + * + * Xibo - Digital Signage - http://www.xibo.org.uk + * + * This file is part of Xibo. + * + * Xibo is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Xibo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Xibo. If not, see . + */ +#} +{% import "forms.twig" as forms %} + +{% block formHtml %} +
+
+ +
+
+
+ {% include "displayprofile-form-edit-common-fields.twig" %} + + {% set title = "Licence Code"|trans %} + {% set helpText = "Provide the Licence Code to license Players using this Display Profile."|trans %} + {{ forms.email("licenceCode", title, displayProfile.getSetting("licenceCode"), helpText) }} + + {% set title = "Collect interval"|trans %} + {% set helpText = "How often should the Player check for new content."|trans %} + {% set options = [ + { id: 60, value: "1 minute"|trans }, + { id: 300, value: "5 minutes"|trans }, + { id: 600, value: "10 minutes"|trans }, + { id: 1800, value: "30 minutes"|trans }, + { id: 3600, value: "1 hour"|trans }, + { id: 5400, value: "1 hour 30 minutes"|trans }, + { id: 7200, value: "2 hours"|trans }, + { id: 9000, value: "2 hours 30 minutes"|trans }, + { id: 10800, value: "3 hours"|trans }, + { id: 12600, value: "3 hours 30 minutes"|trans }, + { id: 14400, value: "4 hours"|trans }, + { id: 18000, value: "5 hours"|trans }, + { id: 21600, value: "6 hours"|trans }, + { id: 25200, value: "7 hours"|trans }, + { id: 28800, value: "8 hours"|trans }, + { id: 32400, value: "9 hours"|trans }, + { id: 36000, value: "10 hours"|trans }, + { id: 39600, value: "11 hours"|trans }, + { id: 43200, value: "12 hours"|trans }, + { id: 86400, value: "24 hours"|trans } + ] %} + {{ forms.dropdown("collectInterval", "single", title, displayProfile.getSetting("collectInterval"), options, "id", "value", helpText) }} + + {% set title = "XMR Public Address"|trans %} + {% set helpText = "Please enter the public address for XMR."|trans %} + {{ forms.input("xmrNetworkAddress", title, displayProfile.getSetting("xmrNetworkAddress"), helpText) }} + + {% set title = "Enable stats reporting?"|trans %} + {% set helpText = "Should the application send proof of play stats to the CMS."|trans %} + {{ forms.checkbox("statsEnabled", title, displayProfile.getSetting("statsEnabled"), helpText) }} + + {% set title = "Aggregation level"|trans %} + {% set helpText = "Set the level of collection for Proof of Play Statistics to be applied to selected Layouts / Media and Widget items."|trans %} + {% set options = [ + { id: 'Individual', value: "Individual"|trans }, + { id: 'Hourly', value: "Hourly"|trans }, + { id: 'Daily', value: "Daily"|trans }, + ] %} + {{ forms.dropdown("aggregationLevel", "single", title, displayProfile.getSetting("aggregationLevel"), options, "id", "value", helpText, "aggregation-level") }} + + {% set title = "Player Version"|trans %} + {% set helpText = "Set the Player Version to install, making sure that the selected version is suitable for your device"|trans %} + {% set attributes = [ + { name: "data-width", value: "300px" }, + { name: "data-allow-clear", value: "true" }, + { name: "data-placeholder--id", value: null }, + { name: "data-placeholder--value", value: "" }, + { name: "data-search-url", value: url_for("playersoftware.search") }, + { name: "data-search-term", value: "playerShowVersion" }, + { name: "data-id-property", value: "versionId" }, + { name: "data-text-property", value: "playerShowVersion" }, + { name: "data-filter-options", value: '{"playerType":"chromeOS"}' } + ] %} + {{ forms.dropdown("playerVersionId", "single", title, displayProfile.getSetting("playerVersionId"), [{versionId:null, playerShowVersion:""}]|merge(versions), "versionId", "playerShowVersion", helpText, "pagedSelect", "", "", "", attributes) }} + + {% set title = "Operating Hours"|trans %} + {% set helpText = "Select a day part that should act as operating hours for this display - email alerts will not be sent outside of operating hours"|trans %} + {% set attributes = [ + { name: "data-width", value: "300px" }, + { name: "data-allow-clear", value: "true" }, + { name: "data-placeholder--id", value: null }, + { name: "data-placeholder--value", value: "" }, + { name: "data-search-url", value: url_for("daypart.search") }, + { name: "data-search-term", value: "name" }, + { name: "data-id-property", value: "dayPartId" }, + { name: "data-text-property", value: "name" }, + { name: "data-filter-options", value: '{"isAlways":"0", "isCustom":"0"}' } + ] %} + {{ forms.dropdown("dayPartId", "single", title, displayProfile.getSetting("dayPartId"), [{dayPartId:null, name:""}]|merge(dayParts), "dayPartId", "name", helpText, "pagedSelect", "", "", "", attributes) }} + +
+ +
+ + {% set title = "Log Level"|trans %} + {% set helpText = "The resting logging level that should be recorded by the Player."|trans %} + {% set options = [ + { id: 'emergency', value: "Emergency"|trans }, + { id: 'alert', value: "Alert"|trans }, + { id: 'critical', value: "Critical"|trans }, + { id: 'error', value: "Error"|trans }, + { id: 'off', value: "Off"|trans } + ] %} + {{ forms.dropdown("logLevel", "single", title, displayProfile.getSetting("logLevel"), options, "id", "value", helpText) }} + + {% set title %}{% trans "Elevate Logging until" %}{% endset %} + {% set helpText %}{% trans "Elevate log level for the specified time. Should only be used if there is a problem with the display." %}{% endset %} + {% if displayProfile.isElevatedLogging() %} + {% set elevatedLogs = displayProfile.getUnmatchedProperty("elevateLogsUntilIso") %} + {% else %} + {% set elevatedLogs = "" %} + {% endif %} + {{ forms.dateTime("elevateLogsUntil", title, elevatedLogs, helpText) }} + + {% if theme.getSetting('DISPLAY_PROFILE_SCREENSHOT_INTERVAL_ENABLED', 0) == 1 %} + {% set title %} + {% trans "Screen shot interval"%} + {{ forms.playerCompat("", "", "R204+", "R208+", "") }} + {% endset %} + {% set helpText = "The duration between status screen shots in minutes. 0 to disable. Warning: This is bandwidth intensive."|trans %} + {{ forms.number("screenShotRequestInterval", title, displayProfile.getSetting("screenShotRequestInterval"), helpText) }} + {% endif %} + + {% set title = "Screen Shot Size"|trans %} + {% set helpText = "The size of the screenshot to return when requested."|trans %} + {% if displayProfile.type == "lg" %} + {% set options = [ + { id: 1, value: "Thumbnail"|trans }, + { id: 2, value: "HD"|trans }, + { id: 3, value: "FHD"|trans } + ] %} + {% else %} + {% set options = [ + { id: 1, value: "Thumbnail"|trans }, + { id: 2, value: "Standard"|trans } + ] %} + {% endif %} + {{ forms.dropdown("screenShotSize", "single", title, displayProfile.getSetting("screenShotSize"), options, "id", "value", helpText) }} +
+ + {% if commands|length > 0 %} +
+ {% include "displayprofile-form-edit-command-fields.twig" %} +
+ {% endif %} +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/views/displayprofile-form-edit.twig b/views/displayprofile-form-edit.twig index aacd403e46..9ef54eebea 100644 --- a/views/displayprofile-form-edit.twig +++ b/views/displayprofile-form-edit.twig @@ -65,8 +65,10 @@ {{ include('displayprofile-form-edit-windows.twig') }} {% elseif displayProfile.getClientType() == "linux" %} {{ include('displayprofile-form-edit-linux.twig') }} - {% elseif displayProfile.getClientType() == "lg" or displayProfile.getClientType() == "sssp" %} + {% elseif displayProfile.getClientType() == "lg" or displayProfile.getClientType() == "sssp" %} {{ include('displayprofile-form-edit-soc.twig') }} + {% elseif displayProfile.getClientType() == "chromeOS" %} + {{ include('displayprofile-form-edit-chromeos.twig') }} {% elseif displayProfile.isCustom() %} {{ include(displayProfile.getCustomEditTemplate()) }} {% else %} diff --git a/web/.htaccess b/web/.htaccess index 093f6fd3b3..e91ceb9a6c 100644 --- a/web/.htaccess +++ b/web/.htaccess @@ -22,6 +22,17 @@ RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_URI} ^/install/.*$ RewriteRule ^ install/index.php [QSA,L] +# pwa +RewriteCond %{REQUEST_URI} ^/pwa/getResource.*$ +RewriteRule ^ pwa/index.php [QSA,L] + +RewriteCond %{REQUEST_URI} ^/pwa/getData.*$ +RewriteRule ^ pwa/index.php [QSA,L] + +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_URI} ^/pwa.*$ +RewriteRule ^pwa/(.*)$ chromeos/$1 [NC,L] + # all others - i.e. web RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d diff --git a/web/pwa/index.php b/web/pwa/index.php new file mode 100644 index 0000000000..b3fa4189b2 --- /dev/null +++ b/web/pwa/index.php @@ -0,0 +1,114 @@ +. + */ + +use Monolog\Logger; +use Monolog\Processor\UidProcessor; +use Psr\Container\ContainerInterface; +use Xibo\Factory\ContainerFactory; + +define('XIBO', true); +define('PROJECT_ROOT', realpath(__DIR__ . '/../..')); + +require PROJECT_ROOT . '/vendor/autoload.php'; + +// Enable/Disable logging +if (\Xibo\Helper\Environment::isDevMode() || \Xibo\Helper\Environment::isForceDebugging()) { + error_reporting(E_ALL); + ini_set('display_errors', 1); +} else { + error_reporting(0); + ini_set('display_errors', 0); +} + +// Should we show the installer? +if (!file_exists(PROJECT_ROOT . '/web/settings.php')) { + die('Not configured'); +} + +// Check that the cache folder if writeable - if it isn't we're in big trouble +if (!is_writable(PROJECT_ROOT . '/cache')) { + die('Installation Error: Cannot write files into the Cache Folder'); +} + +// Create the container for dependency injection. +try { + $container = ContainerFactory::create(); +} catch (Exception $e) { + die($e->getMessage()); +} + +// Configure Monolog +$container->set('logger', function (ContainerInterface $container) { + $logger = new Logger('PWA'); + + $logger->pushProcessor(new UidProcessor()); + $logger->pushHandler(new \Xibo\Helper\DatabaseLogHandler()); + + return $logger; +}); + +// Create a Slim application +$app = \DI\Bridge\Slim\Bridge::create($container); +$app->setBasePath($container->get('basePath')); + +// Config +$container->get('configService'); +$container->set('name', 'PWA'); + +// Middleware +$app->add(new \Xibo\Middleware\ConnectorMiddleware($app)); +$app->add(new \Xibo\Middleware\ListenersMiddleware($app)); +$app->add(new \Xibo\Middleware\Log($app)); +$app->add(new \Xibo\Middleware\State($app)); +$app->add(new \Xibo\Middleware\Storage($app)); +$app->add(new \Xibo\Middleware\Xmr($app)); +$app->addRoutingMiddleware(); +$app->add(new \Xibo\Middleware\TrailingSlashMiddleware($app)); +// +// End Middleware +// + +// Add Error Middleware +$errorMiddleware = $app->addErrorMiddleware( + \Xibo\Helper\Environment::isDevMode() || \Xibo\Helper\Environment::isForceDebugging(), + true, + true +); +$errorMiddleware->setDefaultErrorHandler(\Xibo\Middleware\Handlers::jsonErrorHandler($container)); + +// All application routes +$app->get('/', ['\Xibo\Controller\Pwa', 'home'])->setName('pwa.home'); +$app->get('/getResource', ['\Xibo\Controller\Pwa', 'getResource'])->setName('pwa.getResource'); +$app->get('/getData', ['\Xibo\Controller\Pwa', 'getData'])->setName('pwa.getData'); + +// Run App +try { + $app->run(); +} catch (Exception $e) { + echo 'Fatal Error - sorry this shouldn\'t happen. '; + echo '
' . $e->getMessage(); + + // Only output debug trace if we're configured to display errors + if (ini_get('display_errors') == 1) { + echo '

' . nl2br($e->getTraceAsString()) . ''; + } +} diff --git a/web/xmds.php b/web/xmds.php index 541d551081..bbafaa0c24 100755 --- a/web/xmds.php +++ b/web/xmds.php @@ -24,6 +24,7 @@ use Nyholm\Psr7\ServerRequest; use Slim\Http\ServerRequest as Request; use Xibo\Factory\ContainerFactory; +use Xibo\Helper\LinkSigner; use Xibo\Support\Exception\NotFoundException; define('XIBO', true); @@ -137,12 +138,13 @@ } // Validate the URL. + $encryptionKey = $container->get('configService')->getApiKeyDetails()['encryptionKey']; $signature = $_REQUEST['X-Amz-Signature']; $calculatedSignature = \Xibo\Helper\LinkSigner::getSignature( parse_url(\Xibo\Xmds\Wsdl::getRoot(), PHP_URL_HOST), $_GET['file'], $_REQUEST['X-Amz-Expires'], - $container->get('configService')->getApiKeyDetails()['encryptionKey'], + $encryptionKey, $_REQUEST['X-Amz-Date'], true, ); @@ -188,24 +190,81 @@ // Issue magic packet $libraryLocation = $container->get('configService')->getSetting('LIBRARY_LOCATION'); - $logger->info('HTTP GetFile request redirecting to ' . $libraryLocation . $file->path); - - if ($sendFileMode == 'Apache') { - // Send via Apache X-Sendfile header - header('X-Sendfile: ' . $libraryLocation . $file->path); - } else if ($sendFileMode == 'Nginx') { - // Send via Nginx X-Accel-Redirect - header('X-Accel-Redirect: /download/' . $file->path); + + // Issue content type header + $isCss = false; + if ($file->type === 'L') { + // Layouts are always XML + header('Content-Type: text/xml'); + } else if ($file->fileType === 'bundle') { + header('Content-Type: application/javascript'); + } else if ($file->fileType === 'fontCss' || \Illuminate\Support\Str::endsWith($file->path, '.css')) { + $isCss = true; + header('Content-Type: text/css'); } else { - header('HTTP/1.0 404 Not Found'); + $contentType = mime_content_type($libraryLocation . $file->path); + if ($contentType !== false) { + header('Content-Type: ' . $contentType); + } } - // Also add to the overall bandwidth used by get file - $container->get('bandwidthFactory')->createAndSave( - \Xibo\Entity\Bandwidth::$GETFILE, - $file->displayId, - $file->size - ); + // Are we a special request that needs modification before sending? + // For CSS, we look up the files to replace in required files using their stored path + if ($display->isPwa() && $isCss) { + $logger->debug('Rewriting CSS for PWA: ' . $file->path); + + // Rewrite CSS for PWAs + $cssFile = file_get_contents($libraryLocation . $file->path); + $matches = []; + preg_match_all('/url\(\'?(.*?)\'?\)/', $cssFile, $matches); + foreach ($matches[1] as $match) { + // Look up the file to get the right ID/path. + try { + $replacementFile = $requiredFileFactory->getByDisplayAndDependencyPath($displayId, $match); + + $url = LinkSigner::generateSignedLink( + $display, + $encryptionKey, + null, + 'P', + $replacementFile->realId, + $replacementFile->path, + $file->fileType === 'fontCss' ? 'font' : 'asset', + ); + $cssFile = str_replace( + $match, + $url, + $cssFile, + ); + } catch (Exception $exception) { + $logger->error('CSS has dependency which does not exist in Required Files: ' . $match); + } + } + + $file->size = strlen($cssFile); + + echo $cssFile; + } else { + $logger->info('HTTP GetFile request redirecting to ' . $libraryLocation . $file->path); + + // Normal send + if ($sendFileMode == 'Apache') { + // Send via Apache X-Sendfile header + header('X-Sendfile: ' . $libraryLocation . $file->path); + } else if ($sendFileMode == 'Nginx') { + // Send via Nginx X-Accel-Redirect + header('X-Accel-Redirect: /download/' . $file->path); + } else { + header('HTTP/1.0 404 Not Found'); + } + + // Also add to the overall bandwidth used by get file + $container->get('bandwidthFactory')->createAndSave( + \Xibo\Entity\Bandwidth::$GETFILE, + $file->displayId, + $file->size + ); + } } catch (\Xibo\Support\Exception\NotFoundException|\Xibo\Support\Exception\ExpiredException $e) { $logger->notice('HTTP GetFile request received but unable to find XMDS Nonce. Issuing 404. ' . $e->getMessage()); From 9f1e96fc534b754e1938976fa991c302c3893491 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Tue, 8 Oct 2024 16:04:07 +0100 Subject: [PATCH 02/13] PWA: fix container build. --- .github/workflows/build-container.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-container.yaml b/.github/workflows/build-container.yaml index c030e264f5..01d1ef893d 100644 --- a/.github/workflows/build-container.yaml +++ b/.github/workflows/build-container.yaml @@ -9,7 +9,7 @@ on: - release23 - release33 - release40 - - feature/kopff_chromeos + - kopff_chromeos jobs: build: From ba4c87fe1dfa59ef0fdd1fee6dc1284f4ff876a0 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Wed, 9 Oct 2024 13:18:08 +0100 Subject: [PATCH 03/13] PWA: additional resources for icons --- lib/Entity/PlayerVersion.php | 24 ++++++++++++------------ web/theme/default/img/192x192.png | Bin 0 -> 9136 bytes web/theme/default/img/512x512.png | Bin 0 -> 23058 bytes 3 files changed, 12 insertions(+), 12 deletions(-) create mode 100644 web/theme/default/img/192x192.png create mode 100644 web/theme/default/img/512x512.png diff --git a/lib/Entity/PlayerVersion.php b/lib/Entity/PlayerVersion.php index 5039bc502c..e4dc031120 100644 --- a/lib/Entity/PlayerVersion.php +++ b/lib/Entity/PlayerVersion.php @@ -256,21 +256,21 @@ public function unpack(string $libraryFolder): static $this->config->getThemeConfig('theme_title'), $manifest ); + $manifest = Str::replace( + 'https://xibosignage.com/chromeos', + $this->config->getThemeConfig('theme_url'), + $manifest + ); + $manifest = Str::replace( + 'xibo-chromeos', + $this->config->getThemeConfig('app_name') . '-chromeos', + $manifest + ); } // Update asset URLs - $logoUrl = $this->config->uri('img/xibologo.png'); - - $manifest = Str::replace( - [ - 'assets/icons/512x512.png', - 'assets/icons/192x192.png', - 'assets/icons/48x48.png', - 'assets/icons/24x24.png', - ], - $logoUrl, - $manifest - ); + $manifest = Str::replace('assets/icons/512x512.png', $this->config->uri('img/512x512.png'), $manifest); + $manifest = Str::replace('assets/icons/192x192.png', $this->config->uri('img/192x192.png'), $manifest); file_put_contents($folder . '/manifest.json', $manifest); } diff --git a/web/theme/default/img/192x192.png b/web/theme/default/img/192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..c8b09a8f79b2172e7c32df9eb7ad85b065b94b0b GIT binary patch literal 9136 zcmZ8{bx>SQ^yM)4-~{f@VLh6mzWA6f3I^@~0PX014QZ@#v<3j6m;E3ksqJHYl7;^H zWA86SU>%$lp5T&Lp8(+RBvsxw6?%^kBvqhAGPc2FY-NZ`=rPogQozX5XH#I#V>nk} z_QfN_X2?s0vE-$1qA2sAeJo7Qa4>+|!T%w$+Vpxhoc}qju&u24?>$`D?5@IBx!uRo zd2YrsgSt%Lkz%8T{2%iMz$^?VKO}?{@M-gP+$vD(+T`Z=GEFOndidMv$~Lx+t&8!g z93;fcaYPlQzc;5ys(t2R_m((HE=`H_@`Z3|5P{TF6`aS9OQQQOXAP94a6Ko!avKa( zVMG8;T~#ou!ilI05=2(Aqe&nkMC6_-5MskJWP@3G&T+H2kGSRrh*{;lN36nAVn5%c zpBQG6mKfs~BL#Wt*E@3tI*X6?ptN9JSj&$~z4|`E(g0;?8di1Ok=&ETgwg;MbseRw&?mzL`Uy4fA7Rngjd$0`6!G-=gVeGP&oE|0*vpdi^I0-ds zDWLu~QGZRZ@c!J>kUUd>THurXy3!Ug`2t|4i1)T$y#DgoHz<=qBb3vW!0|ci%=Ql- zUYdN-*OX6-1Nep>3W!=qUD>YpGH*0;6mkYuuWMtemCK8A1FeNk?{Pt|4qC65X*LJj z(nWKJGc;%f|KiBh7M6lgCV0;J2@}u!`OdR8_{X|1aX?3) z9uKb0?HN5RS+wMA{yw{x(s{P_$ncxlUDmArLz`m*({x zzh!I<*gvuugrAchp+1#J$?NDv0bhYMG_cw;$9EaAtmJ9oX$m+}z>wRjJomVZ7gLcj zW5#)7o5wPAku&9U0%b;^8#EG5dTJ%;0j~9y?tX4U1{nkD9Id@ts2E|F9~|4#0!~w5 zRmX4p2HM}rC~U=U=iX=jUcg|KNbT#r=&Ki*#ileTuQMYLQ<{uB4=a!3V2<3Z#AbBk zF&`h47;g8~^C-i=^u1;rKUYTD_fPS3{}_pJUV%*f$s*=)g zQ|$PA8qi@_a!TE^El1u%xeqz~tS@DKy-9CtrbXtp=o&1~^}9%kkP~?L*5S}`w~Owi zRnR{Jv4p&TDhpMxSL+96vj8AZTQcN$D+J8u;CCN!Xcg$AqmaeZP}DKK(TFnW1$tgV zwk-SxCo}5!Dfh-!8dM~y^-sma0!w^E$=9mY{DYZ5_-ci0dOOwQob-zTqn~E3&^g(p zicE7Dw9%4&8-H^H>ADCe5q8$UAM(}!d2OQ-(TemFRkaXUIrOR6N6W7jYP)dp6P+~U zq`&7$A1T9CT_|%d5O6nevi$)-90YvF7syRp-F}nunaMD)rtf!5y?4@Fpk9C&u`3Zp zNCCd{xgt`79EqjyjKQnrR&~H6?5Cn><0vdy&;b%?_FCr+sReIC^2Inau1p&pCEDBh z9e-0Zx92K!pL{@48(Mm(hE9=4M%0^?0OBxH_|MO&B6LhC%zz&7NRo%$%rRLJu=<9| z>Bbt6j-erWqjUF3}756b|Hgw3!hh9ojx+dIWO?P zhs9=pZq{Srus4FLVdNQGrRzLNEZv`E)6y(6t%aHcaM9JTblgkbJUZ65V$ma1_i>)bN_8}#?wyY!!7O@h`GwE7lec#eTo1y z>L>os=|kc;BjXe_UNT4vc%wxpWK`Y3vZIzk2ApI#8<2>dYQ;G<|4`*NpT-zeMcRx{ z?Vet@7Xzr8IdXKj`@V-yB(x{|8&e3OD|ZfSDk^h*&^3(GCIwI2;*Cp>I?DK$8#yX>p!&e|%v z?lb_$`)Qc9Ms48%frm3ug0ZT|Sdd*YeLaLqSoR>#B^x(Beb=?`at)jUY1$Vd9I$qFp?w>*(Ur8~&nq z4Zp0kL)`@MMUTdj@H+a?)+OR`T&(SMwNJxQVwQ`zpKoi&z{WJ3$P6c~7J@EYWp@UE z3QF8HXOq7u<3zc9WJfG|O{%BB$7h2wf-ePZDe~u=UW~>_@7aD!RY-X+r2=J}7%so| z95)A{?KAqVYl5X?nA2}F>JXJ&ImqYV-u#(MDc83%xqd;D7_gM~?pL~KJbISYv*8Sv zl74jTx%ZgZYkTHu0IpPm!b}Rk59=+YBKJWiIpL-FaWQL+DrNq-CM^!DwxxZzNP4inu2f znV^YWbuz(aa2#CVxgvNxBm*IS;T=7~d`doQXjFzdcJ|_F9%DkrWmw4yc(ysRSz)x2 zKNpDKSLR_|uK4q=K9e%%5jGv*4AQDSKKt)Y+=~=2O3XbNZX8?If#~1Iq*entE5W~c zI{dJjb_imAQQ|aLpfD0W_Yz%UEwqo39T*UOl0oe?&sj?Q^z`2)OPeU+)p- zHMZhsIgO|AX6pItQKRAPFSX@i*sNh`6T_T2F-MtL3x-O{Y4T{1oy{- z7cE!LN|UfjGK|=V@IAiDCw76#77JT$yWlM+?5l{820&{CS z(ALpHl%kJ^Yn+2QPcBhg`C^04vFm~Uvysmw;fR?qL_seKS9e1r9`sHELI)*1y8h@N z5?#W+CG3Y?jhdXBg7b#LVqjhO{XqOsj#?R_RmMpSEi|Ygg~p&RxYl6RH5N}h2uIW{Qy}EwlqWg?zG{t4UKHyT70KEV&Pvg z>8vfX)B3aFRRdF)nT4y zRqwuE8UG&h4h{ZkVNf2$UTsjMT~ble{Kp}IUuNd1gelmiQdPQ+MuIU+o+RnVB3f;( zr65l1V&drks5t&-{m~^H>BKqVqP{%Cr)bLvPf<2afFgG*XO&mTPNd~1_K8GU-pS5n zu0aULcHY{V8d1-2eLxAoysM2qKrT8DFmh+dmxS??6abE1st}q4WL1N2y_XK+hb+fA zCi!4%UES~tLz#_0eg8TZ^{i{Y@%0;Or9AOc`K`3r0Eaks)?+0EUFhjg43ZuxpEP-I z^$3J51mnFNk^)4_Z$7hc=Y@ADleivh;e7UOb=WI?m!Ghk^NZnIVQR&U&w-X{gG}vU ztvz$Euq8;`S*h=|X4TfGR9Ku!M{wk0_HSQkTZtTzfIWNl@7+fydJAOius9eWs_ zRwooINKF=^6kmz8<%I(^U^VDs>tZ|ysjns71<332Tl;CaqEUq-7O>K|Rm~VsP4dYm zYInayCM2t6dC)Sn_WyS03%GB|L?NeZN=^+}_#VA;BT9;EbH#MQgAksLf->n{{JufM zU69<-8|fV?KVw^i<_0m69M;Yfrpj;4y|%1DQHMn0DM<;`{SqW8Unn`kw?Va0C>KnL26H8n1 zmcTef<*}xkISS(rC-TF_^g%~z>I=U49JfaV*;j9Z8QzFnTtNRJlh1dI-mv$Fh+JLu zj@GC|7gS%U-}e6-%`u#diQuOKeRlsY6~>OU5~`NedsLfrIXTZ8o29~tkypnZO~3eR zz^+oltZEC7gy{L;#NcC%4)*O8Ke5;15Mgc%ae!pGgNZn*8Nqo%DB7OlkR{4wmsfr_cV&cul3z?R_fDfh^ znLu7%%OMtUU9CaVs!M%$>??LjYIeHMvzJ}3K38Q4o;wWZM+Da=LKk%uwb#fT+ir($ ziUCY2dUw<|T1j4zzmt?D7~vGo~%i@r(%+tC{i)aQTkfiXInw2HJB0kLZ)8-Un1C9TEt z{6(V6^-Ea5BES~sfi_vp^iItj|F!O2HT4n2J6yDy9(46QE8cC?1Dk80(Q@h^$xPErBoj-fzOO; zDy$|eKJ>%Ue1*+>_#RKXOTT)lE8h zP=dcWM%W+UMx2DI*I0M0|9xccv9*k1xSgJiuTr{uonkN_>t(Im$`X|O_s#J2KYzUB ztJmD3Ic@&`8=1DeTE?sBW3wS%4Dei3m_^84I4BZ7W%$ebHKV*b`|^V3L7eaxRbNZm zKW_-ccDCvrOvtfce(qz9v*I&BhTd*>Qw)ZcU6Gt{Z#L*W1-itQS8Z1TGM`L*T(!Sl z<&No6*?yYyLarL%&cP76m0qo@yv>nd0r={W)Qb%5LZ)}xX$PsBz+d;zp$0W-M%G!= zUOM$m>s`8H3jYx8oX=59!#5n?be#Bm z#3`_EvvRKhUzz_f1bZA&#^Aa#eJnvDpu5OJ{H^U1p2OYmaJKjIcE_a}64RpU%B0w< zqc8_=&5fP@6+4^$Yw{PQZV;*FJCr!^po5}oiF9Xk>3~Z zz{|W53G2Y7`Nt9HXyO)gZJ=YodS>as1?{CLS1K{)X6(kCvLD=C=m=0Qh%yJn@>rod z>ScAZz$C!;-CUOXk8MmrOuL4lcMBIVhr_2+I@4W;NKu4r1=Ymz@o=To4z%`7uzk*t zj)0vfa<=2A&*&i-fj{CzH@3(dtMy93y}z5|+k4w7Nr9yR$HDz>&9LcwGY=|gPTv#Q z?$U~1B~1p_1O5Jhbe{FX^-cif-M0qz+}BJGjM7r?i#ZFc#XN@L9!95=`V68-$u8!^ z`bm%^FP+yePbR#aKDZ?u{mX_MrnyC5>7cN=JF}t&|26_0Qt*OkmcV#K zQz=0|D!JOwE7nv>sgK{c6AD=CnIc#~MlUHPBs|6^N==c#kg_S?;GwmQa101AF*iH zxrDsxEIo(b7BF8Ae(GZaP1cH0Mzc$C7bD^g zO^q<)q;`nsPw88%$F+LIlbG5by9<=g<rYq%+j#z`Z}e=DDmI_(|lhT7!k!T=fpep)P43zjA5YfVh!VvAs9)m zn*9o_3`5-*ZX5Ya2r3p_VddJ^IZzz*qR?xZad?hyXr-W;O;_%?C7_RWe5UDSyz z88u8dQ1zEgQR6#oU;!XQqLt-U_*`-It>X`tyTsM$u1mdZsm4OXPvb_#*c4G3uo&9a z8NSGrYdCvAj&0QAV7K({U-9lFWyX-k>0whY(0SmCLeo2X$C8L`6NRJ^3SX#BZD2IV zs?hp^V@70(Ji}+zXIGTSPVJn=WWG$9o~0LArrxJO&G5oPqgGcP2QQ z^V!j;UX#PG&4||B)o&@%A-(mOY@t}$cMgYInuGfv9~Ml_i8`&K`uOt1h?`S->neEK zrKu~k6jS%#98J)YqSDSroGOqCUL>p4joqeTPupq-OQ3e#(@A+z+b}VM~XYQsI!*vb$m?iqxoZRhSxL*`%W|nd780GyMy*NTgfLQTm%l z88VPrP_Xc-iKMepAG02)e%tZep~UIc5acyx`~$=EuyWKy4kYp3auyA_PLKZwy;&H6 zLvlrK4UHOAAH%Y7oBj$T4P7&Zn9wp)GQRMT@NDxRa7D z`!7|T>=qfnNVN9e{KBQj4>vw}CS}rk^)f~B7z{z$uof9B9-JraD7e1$N6AlzCXz+> zke|&`Xc?7+Y8HJ{mI+4n+>FxQp3S}B1JKg|Cwi;|D*%#XWrcY(Kzv#>Je6h_F_^UT zhH*71du+TVwwJ1$Czc@f7=yLTe9o-;g@4Vwo$|ksAI|jS_mWDAfr0Im`nJ0kt2LI> zDs@@Ky#)J&w$gkJ51y7X*o02DI`K=Eb6<^_a9o%Lj^$HyugO2S-n(FROso07Ud2OCAjn%<_isE$(#UlfH5)tWJZ&E;c_w@W(`_=Q}sKVlw3gocAOoGpTe>QR$gqr`;u5I>;r<}r%F zNZ=P8Y_uSCfZ?yKbSeib!OaA1_v2UakLnYQiF@R3{xd90vy9y4$F*meig>icE$zR_S*XqqOfl2M>dV6%yWwx5f&YgH@ADs#>%P(1=F&n$t%EXh2eYS;y(_1sA@*; z4tzYtmESMFo76-;_sxUUp#iCX9>gbv$a&8u8#x<%xohvTdPqIH^Sn;&KUVE%r3;67N)17uw!`O9>Nf?_1-H*M(nHdF+MA1}isTB+X(yJM4uN z=^g}QA_OdMq1f+c$IyrUiGul0++6eCjH6~=eFXV0GOlfUqb~gS3d@nBCO>+xN+&`D zFMifmbzyJ}8s$u!+rUL-{eJajVu6kS@(V-_=uR_2)xEp7ee%oxa6S+st^taUgB_}K z6JGZF^CxaKe-pCsQaI{0ovNfcnLn2SKz{6XUp;x5EhHa+W%~84;|$ASoD@)OP$4xm zDyJWy`a2!WjG0VyW#xI?WtcvA`L&B~Tm$4Oj-q#72NbwMyyi^TF`V&tvD7JyNmowx z3f6$p_1gXx%u?GDWcxmBL3`{GQSe9koTFVE_8n_ZasI?c6+U;z1*3^qk5(l;#Q}s9 zo3KhSQzXz<6FR$3Fz+mQG81JwMRUFI$Osb%Z83^G_ z6t4e6bj|Wii6Sld1YvaW=+GA}-Y~7rbLlYg`;EXeVFiTE+ukM+epe{~kTkQiMgxh+ zHh&9ChW4TI|D=tl0IKdgIqRZQ;SFMn=>Qmwhy-g4i;j^fML%%TW{M47UHu>nI6?AD z%RHUlNe!dr^Agj~{G!KV^C*NC@7Ws5p6bOZ5EeYWwvj5Ranzd%l8Do3csj0*MN>Qdr@8=ixz`2%W7wrF~v>QbC4ic0O;*wn&0NHb`i=6{PZ-U ztT7)q6+hslYW{?GH&HF zRzsKfCaW4vkqnhtlR=_95nbRfV9?e=u*$WCWgS>?#R@-GdMpPJl2056bjCm!Mc-sA zL_J(H9@$K^PIJQ5?@8BKC0NN4ALgg91k7@L%->q2M&T?HTPNsH@>VT2J72it(YBiI zg;)|Maq99byl-;h&Qtr5(CR?^^pXd`a=TMB=EPCqUw%X@Z@af}(fX^M)T49wyllQC z_gygk+vMw$mk<{UtbXm2o!%s++MFiV zFkM_G4eXeoPtW=$c;B{Phiv7{@))7B9zhpU{w*wB>Y^>SuGdAXMdioTE_E9u_kHBNO=Ntx8o<$^Kzbk!I%*(kC-2-3|uv}5iH5X9kE^4|)y40jo*7dckGAl;IgQ2{xB+iv-J)ow?ilCCa=$c4+!`&+ zq>tMP69J5U6Ywb&S$^FQhQzGk%_BT1;3gba$~a4zjPnx`COUt8+{|gJ;pEve z>m=AdJ6USMi~lcRPClME_xqxjvhc22Q@D&KJnldhbgWdGvXDzZGs|a{ukPm@yVTM9 z+dTb`abX}CgvQs0L?2iN04AF`v`Y3K(}^R;(ZZ`rAKYsbl-V?@b}4-Xx!z_Hg(Ka zAci*36>I@aON_aS44Y4n%|F^ojp?K!Xo5=|g>=)3cJilxrov2bRnEBKqklwnGsAGg zq;@+8hQJ+Y!G1)BROyOxP^x`9EiO}qN|!UIx{-$m@t@ug`uphO)=jP~(zh4k;e7p952;Qcj0*-4y@6qg$9azwj!6Y{W7 zO&twP|N zN`;F`h;{Y0VZWkuH?0bJsU<=KroynAv`S81T;~Iv?s@!hhL42+Wn6eQY}06sPD;qQ zxx6X>gfL|9lMfyWXUZBx`O{9jG|T^$@fDjkSH|1_>R0qMR8mDRfXDd4^l7sPcqd4e zn6Hr2esF&nrv+`uJqB!S^{zLjG3)UfHd;A~cWQN4ao}a_zlbmmx^}u*#7E#4`2ixr0Aeu%-b8hc} zb#6JNFW+wCrr%s1xREFcyhztE_+i3j?gOk9eoK|HXm*k<|DRy|f5l`{LKp)5FYiX% Vz3&6%%Pkwg2U%5_N-5Le{{{1DQHTHl literal 0 HcmV?d00001 diff --git a/web/theme/default/img/512x512.png b/web/theme/default/img/512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..b693e0d3aaea790abcee7cdd59dec3800724f2e7 GIT binary patch literal 23058 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 literal 0 HcmV?d00001 From a542e42db1151bb7cf4c2e4b1e52ecc5417508bc Mon Sep 17 00:00:00 2001 From: Ruben Pingol <128448242+rubenberttpingol@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:14:51 +0800 Subject: [PATCH 04/13] Fix: JavaScript asset Content-Type (#2769) relates to xibosignageltd/chromeos#18 --- web/xmds.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/xmds.php b/web/xmds.php index bbafaa0c24..608b67b9fa 100755 --- a/web/xmds.php +++ b/web/xmds.php @@ -196,7 +196,7 @@ if ($file->type === 'L') { // Layouts are always XML header('Content-Type: text/xml'); - } else if ($file->fileType === 'bundle') { + } else if ($file->fileType === 'bundle' || \Illuminate\Support\Str::endsWith($file->path, '.js')) { header('Content-Type: application/javascript'); } else if ($file->fileType === 'fontCss' || \Illuminate\Support\Str::endsWith($file->path, '.css')) { $isCss = true; From bb8cfc6263db10809463658f568da8072132088d Mon Sep 17 00:00:00 2001 From: Ruben Pingol <128448242+rubenberttpingol@users.noreply.github.com> Date: Tue, 3 Dec 2024 18:29:36 +0800 Subject: [PATCH 05/13] Layout Preview: Video rendering error handling (#2814) * Bump XLR to latest version * Bump XLR version - Video rendering error handling --- package-lock.json | 252 +++++++++++++++++++++++++++++++++++++++++++ package.json | 3 + ui/bundle_preview.js | 4 +- 3 files changed, 257 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3146881739..538a9d49be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "@fortawesome/fontawesome-free": "^6.6.0", "@mapbox/leaflet-pip": "^1.1.0", "@popperjs/core": "^2.11.8", + "@ssddanbrown/codemirror-lang-twig": "^1.0.0", + "@xibosignage/xibo-layout-renderer": "^1.0.4", + "@xiechao/codemirror-lang-handlebars": "^1.0.4", "ajax-bootstrap-select": "^1.4.5", "blueimp-file-upload": "^10.32.0", "blueimp-load-image": "~5.16.0", @@ -1802,6 +1805,134 @@ "@egjs/component": "^3.0.2" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.18.2", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.2.tgz", + "integrity": "sha512-wJGylKtMFR/Ds6Gh01+OovXE/pncPiKZNNBKuC39pKnH+XK5d9+WsNqcrdxPjFPFTigRBqse0rfxw9UxrfyhPg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + }, + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz", + "integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.0.tgz", + "integrity": "sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", + "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz", + "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.3.tgz", + "integrity": "sha512-kDqEU5sCP55Oabl6E7m5N+vZRoc0iWqgDVhEKifcHzPzjqCegcO4amfrYVL9PmPZpl4G0yjkpTpUO/Ui8CzO8A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz", + "integrity": "sha512-PDFG5DjHxSEjOXk9TQYYVjZDqlZTFaDBfhQixHnQOEVDDNHUbEh/hstAjcQJaA6FQdZTD1hquXTK0rVBLADR1g==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.7", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.7.tgz", + "integrity": "sha512-6+iLsXvITWKHYlkgHPCs/qiX4dNzn8N78YfhOFvPtPYCkuXqZq10rAfsUMhOq7O/1VjJqdXRflyExlfVcu/9VQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", + "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==", + "license": "MIT" + }, + "node_modules/@codemirror/view": { + "version": "6.34.2", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.34.2.tgz", + "integrity": "sha512-d6n0WFvL970A9Z+l9N2dO+Hk9ev4hDYQzIx+B9tCyBP0W5wPEszi1rhuyFesNSkLZzXbQE5FPH7F/z/TMJfoPA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2116,6 +2247,63 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", + "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.9.tgz", + "integrity": "sha512-TYwgljcDv+YrV0MZFFvYFQHCfGgbPMR6nuqLabBdmZoFH3EP1gvw8t0vae326Ne3PszQkbXfVBjCnf3ZVCr0bA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", + "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", + "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.19.tgz", + "integrity": "sha512-j44kbR1QL26l6dMunZ1uhKBFteVGLVCBGNUD2sUaMnic+rbTviVuoK0CD1l9FTW31EueWvFFswCKMH7Z+M3JRA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", + "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@mapbox/leaflet-pip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mapbox/leaflet-pip/-/leaflet-pip-1.1.0.tgz", @@ -2206,6 +2394,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ssddanbrown/codemirror-lang-twig": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ssddanbrown/codemirror-lang-twig/-/codemirror-lang-twig-1.0.0.tgz", + "integrity": "sha512-7WIMIh8Ssc54TooGCY57WU2rKEqZZrcV2tZSVRPtd0gKYsrDEKCSLWpQjUWEx7bdgh3NKHUjq1O4ugIzI/+dwQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -2455,6 +2654,26 @@ } } }, + "node_modules/@xibosignage/xibo-layout-renderer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@xibosignage/xibo-layout-renderer/-/xibo-layout-renderer-1.0.4.tgz", + "integrity": "sha512-kW0dTJWIYA3nOjdgn+Rjw7d+To3jsE1sb0z89A+9ovtLqpVkZHpkhgtXs+j7GcxemAqOH70WASb83KTbHGFBbg==", + "license": "LGPL-3.0" + }, + "node_modules/@xiechao/codemirror-lang-handlebars": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@xiechao/codemirror-lang-handlebars/-/codemirror-lang-handlebars-1.0.4.tgz", + "integrity": "sha512-ghOpKUrRvvPQnvoVXY8axEA3xVFxC8M0zNDgiUdfJykqCMxusb3pN9ZbYYg/8KuoGUR/LDd2rb6eaW7ftcCqOg==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.4.7", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "codemirror": "^6.0.1" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -3449,6 +3668,21 @@ "node": ">=0.10.0" } }, + "node_modules/codemirror": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", + "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -3666,6 +3900,12 @@ } } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/croact": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/croact/-/croact-1.0.4.tgz", @@ -8251,6 +8491,12 @@ "webpack": "^5.27.0" } }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -8803,6 +9049,12 @@ "extsprintf": "^1.2.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", diff --git a/package.json b/package.json index 2f7ab61367..d0285f6d86 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,9 @@ "@fortawesome/fontawesome-free": "^6.6.0", "@mapbox/leaflet-pip": "^1.1.0", "@popperjs/core": "^2.11.8", + "@ssddanbrown/codemirror-lang-twig": "^1.0.0", + "@xibosignage/xibo-layout-renderer": "^1.0.5", + "@xiechao/codemirror-lang-handlebars": "^1.0.4", "ajax-bootstrap-select": "^1.4.5", "blueimp-file-upload": "^10.32.0", "blueimp-load-image": "~5.16.0", diff --git a/ui/bundle_preview.js b/ui/bundle_preview.js index 550fdb3abe..7c266c0315 100644 --- a/ui/bundle_preview.js +++ b/ui/bundle_preview.js @@ -22,8 +22,8 @@ window.jQuery = window.$ = require('jquery'); // XLR -require('xibo-layout-renderer/dist/styles.css'); +import '@xibosignage/xibo-layout-renderer/dist/styles.css'; -import XiboLayoutRenderer from 'xibo-layout-renderer'; +import XiboLayoutRenderer from '@xibosignage/xibo-layout-renderer'; window.XiboLayoutRenderer = XiboLayoutRenderer; From bb87ddd7f74d7e62168160386b7e139ea8291783 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Tue, 10 Dec 2024 16:25:25 +0000 Subject: [PATCH 06/13] Web Sockets XMR (#2826) * XMR: initial work for CMS->XMR communication over private API, remove ZMQ dependency. xibosignageltd/xibo-private#880 --- Dockerfile | 6 +- Dockerfile.ci | 6 +- Dockerfile.dev | 6 +- composer.json | 1 - ...31114002_old_upgrade_step121_migration.php | 25 +++- docker-compose.yml | 5 +- docker/entrypoint.sh | 7 +- .../apache2/sites-available/000-default.conf | 4 + lib/Helper/Environment.php | 9 -- lib/Service/ConfigService.php | 6 - lib/Service/PlayerActionService.php | 95 +++++++------- lib/Service/PlayerActionServiceInterface.php | 20 +-- lib/XMR/PlayerAction.php | 121 +++++++----------- lib/XTR/MaintenanceDailyTask.php | 32 ++++- lib/Xmds/Soap5.php | 5 + tests/Helper/MockPlayerActionService.php | 13 +- tests/Helper/NullPlayerActionService.php | 11 +- views/display-page.twig | 2 + views/settings-page.twig | 4 +- 19 files changed, 199 insertions(+), 179 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1ff3f30f3e..e8fcef7e5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,7 +72,6 @@ RUN LC_ALL=C.UTF-8 DEBIAN_FRONTEND=noninteractive apt update && apt upgrade -y & iputils-ping \ php8.2 \ libapache2-mod-php8.2 \ - php8.2-zmq \ php8.2-gd \ php8.2-dom \ php8.2-pdo \ @@ -103,7 +102,10 @@ RUN update-alternatives --set php /usr/bin/php8.2 # Enable Apache module RUN a2enmod rewrite \ - && a2enmod headers + && a2enmod headers \ + && a2enmod proxy \ + && a2enmod proxy_http \ + && a2enmod proxy_wstunnel # Add all necessary config files in one layer ADD docker/ / diff --git a/Dockerfile.ci b/Dockerfile.ci index ae9eaa15bd..3b3338ba04 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -57,7 +57,6 @@ RUN LC_ALL=C.UTF-8 DEBIAN_FRONTEND=noninteractive apt update && apt upgrade -y & iputils-ping \ php8.2 \ libapache2-mod-php8.2 \ - php8.2-zmq \ php8.2-gd \ php8.2-dom \ php8.2-pdo \ @@ -88,7 +87,10 @@ RUN update-alternatives --set php /usr/bin/php8.2 # Enable Apache module RUN a2enmod rewrite \ - && a2enmod headers + && a2enmod headers \ + && a2enmod proxy \ + && a2enmod proxy_http \ + && a2enmod proxy_wstunnel # Add all necessary config files in one layer ADD docker/ / diff --git a/Dockerfile.dev b/Dockerfile.dev index 574b5bd4df..0b269c6038 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -24,7 +24,6 @@ RUN LC_ALL=C.UTF-8 DEBIAN_FRONTEND=noninteractive apt update && apt upgrade -y & iputils-ping \ php8.2 \ libapache2-mod-php8.2 \ - php8.2-zmq \ php8.2-gd \ php8.2-dom \ php8.2-pdo \ @@ -55,7 +54,10 @@ RUN update-alternatives --set php /usr/bin/php8.2 # Enable Apache module RUN a2enmod rewrite \ - && a2enmod headers + && a2enmod headers \ + && a2enmod proxy \ + && a2enmod proxy_http \ + && a2enmod proxy_wstunnel # Add all necessary config files in one layer ADD docker/ / diff --git a/composer.json b/composer.json index 56325e6fdf..b1c215cd63 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,6 @@ "config": { "platform": { "php": "8.1", - "ext-zmq": "1", "ext-mongodb": "1.15.0", "ext-gd": "1", "ext-dom": "1", diff --git a/db/migrations/20180131114002_old_upgrade_step121_migration.php b/db/migrations/20180131114002_old_upgrade_step121_migration.php index b44e94f22b..8e6d46e1ea 100644 --- a/db/migrations/20180131114002_old_upgrade_step121_migration.php +++ b/db/migrations/20180131114002_old_upgrade_step121_migration.php @@ -1,5 +1,24 @@ . + */ use Phinx\Migration\AbstractMigration; @@ -32,7 +51,7 @@ public function up() 'setting' => 'XMR_ADDRESS', 'title' => 'XMR Private Address', 'helptext' => 'Please enter the private address for XMR.', - 'value' => 'tcp:://localhost:5555', + 'value' => 'http:://localhost:8081', 'fieldType' => 'checkbox', 'options' => '', 'cat' => 'displays', @@ -40,7 +59,7 @@ public function up() 'type' => 'string', 'validation' => '', 'ordering' => '5', - 'default' => 'tcp:://localhost:5555', + 'default' => 'http:://localhost:8081', 'userSee' => '1', ], [ diff --git a/docker-compose.yml b/docker-compose.yml index 482cff2628..4cdf5f965d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: db: image: mysql:8.0 ports: - - 3315:3306 + - "3315:3306" volumes: - ./containers/db:/var/lib/mysql environment: @@ -12,12 +12,11 @@ services: MYSQL_DATABASE: "cms" xmr: - image: ghcr.io/xibosignage/xibo-xmr:latest + image: ghcr.io/xibosignage/xibo-xmr:develop ports: - "9505:9505" environment: XMR_DEBUG: "true" - IPV6RESPSUPPORT: "false" IPV6PUBSUPPORT: "false" web: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 9576ca72d5..3a9ce9de3a 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/bash # -# Copyright (C) 2023 Xibo Signage Ltd +# Copyright (C) 2024 Xibo Signage Ltd # # Xibo - Digital Signage - https://xibosignage.com # @@ -32,7 +32,6 @@ then echo "" echo "XMR Connection Details:" echo "Host: $XMR_HOST" - echo "CMS Port: 50001" echo "Player Port: 9505" echo "" echo "Starting Webserver" @@ -120,7 +119,7 @@ then mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='Apache', \`userChange\`=0, \`userSee\`=0 WHERE \`setting\`='SENDFILE_MODE' LIMIT 1" # Set XMR public/private address - mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='tcp://$XMR_HOST:50001', \`userChange\`=0, \`userSee\`=0 WHERE \`setting\`='XMR_ADDRESS' LIMIT 1" + mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='http://$XMR_HOST:8081', \`userChange\`=0, \`userSee\`=0 WHERE \`setting\`='XMR_ADDRESS' LIMIT 1" # Configure Maintenance echo "Setting up Maintenance" @@ -194,7 +193,7 @@ then mysql -D $MYSQL_DATABASE -e "UPDATE \`user\` SET \`UserName\`='xibo_admin', \`UserPassword\`='5f4dcc3b5aa765d61d8327deb882cf99' WHERE \`UserID\` = 1 LIMIT 1" # Set XMR public/private address - mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='tcp://$XMR_HOST:50001', \`userChange\`=0, \`userSee\`=0 WHERE \`setting\`='XMR_ADDRESS' LIMIT 1" + mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='http://$XMR_HOST:8081', \`userChange\`=0, \`userSee\`=0 WHERE \`setting\`='XMR_ADDRESS' LIMIT 1" mysql -D $MYSQL_DATABASE -e "UPDATE \`setting\` SET \`value\`='tcp://cms.example.org:9505' WHERE \`setting\`='XMR_PUB_ADDRESS' LIMIT 1" # Set CMS Key diff --git a/docker/etc/apache2/sites-available/000-default.conf b/docker/etc/apache2/sites-available/000-default.conf index 8d7da80d16..b11ca2b2f5 100644 --- a/docker/etc/apache2/sites-available/000-default.conf +++ b/docker/etc/apache2/sites-available/000-default.conf @@ -45,6 +45,10 @@ ServerTokens OS Require all granted + + ProxyPass ws://xmr:8080 + + ErrorLog /dev/stderr LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" *%Ts* *%Dus*" requesttime diff --git a/lib/Helper/Environment.php b/lib/Helper/Environment.php index d342e40b05..4c3474e28b 100644 --- a/lib/Helper/Environment.php +++ b/lib/Helper/Environment.php @@ -264,15 +264,6 @@ public static function checkPHPUploads() return true; } - /** - * @inheritdoc - */ - public static function checkZmq() - { - return class_exists('ZMQSocket'); - } - - public static function getMaxUploadSize() { return ini_get('upload_max_filesize'); diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index 1cd41e7137..4a05650872 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -732,12 +732,6 @@ public function checkEnvironment() __('cURL is used to fetch data from the Internet or Local Network') ); - $this->testItem($rows, __('ZeroMQ'), - Environment::checkZmq(), - __('ZeroMQ is used to send messages to XMR which allows push communications with player'), - false - ); - $this->testItem($rows, __('OpenSSL'), Environment::checkOpenSsl(), __('OpenSSL is used to seal and verify messages sent to XMR'), diff --git a/lib/Service/PlayerActionService.php b/lib/Service/PlayerActionService.php index 1b5d6a58a5..2849bae3d7 100644 --- a/lib/Service/PlayerActionService.php +++ b/lib/Service/PlayerActionService.php @@ -1,6 +1,6 @@ config = $config; - $this->log = $log; - $this->triggerPlayerActions = $triggerPlayerActions; + public function __construct( + private readonly ConfigServiceInterface $config, + private readonly LogServiceInterface $log, + private readonly bool $triggerPlayerActions + ) { + $this->xmrAddress = null; } /** * Get Config * @return ConfigServiceInterface */ - private function getConfig() + private function getConfig(): ConfigServiceInterface { return $this->config; } @@ -75,7 +65,7 @@ private function getConfig() /** * @inheritdoc */ - public function sendAction($displays, $action) + public function sendAction($displays, $action): void { if (!$this->triggerPlayerActions) { return; @@ -86,38 +76,48 @@ public function sendAction($displays, $action) $this->xmrAddress = $this->getConfig()->getSetting('XMR_ADDRESS'); } - if (!is_array($displays)) { - $displays = [$displays]; - } - - // Check ZMQ - if (!Environment::checkZmq()) { - throw new ConfigurationException( - __('ZeroMQ is required to send Player Actions. Please check your configuration.') - ); + if (empty($this->xmrAddress)) { + throw new InvalidArgumentException(__('XMR address is not set'), 'xmrAddress'); } - if ($this->xmrAddress == '') { - throw new InvalidArgumentException(__('XMR address is not set'), 'xmrAddress'); + if (!is_array($displays)) { + $displays = [$displays]; } // Send a message to all displays foreach ($displays as $display) { /* @var Display $display */ - if ($display->xmrChannel == '' || $display->xmrPubKey == '') { + $isEncrypt = false; + + if ($display->xmrChannel == '') { throw new InvalidArgumentException( sprintf( __('%s is not configured or ready to receive push commands over XMR. Please contact your administrator.'),//phpcs:ignore $display->display ), - 'xmrRegistered' + 'xmrChannel' ); } + if ($display->clientType !== 'chromeOS') { + // We also need a xmrPubKey + $isEncrypt = true; + + if ($display->xmrPubKey == '') { + throw new InvalidArgumentException( + sprintf( + __('%s is not configured or ready to receive push commands over XMR. Please contact your administrator.'),//phpcs:ignore + $display->display + ), + 'xmrPubKey' + ); + } + } + $displayAction = clone $action; try { - $displayAction->setIdentity($display->xmrChannel, $display->xmrPubKey); + $displayAction->setIdentity($display->xmrChannel, $isEncrypt, $display->xmrPubKey ?? null); } catch (\Exception $exception) { throw new InvalidArgumentException( sprintf( @@ -142,7 +142,7 @@ public function getQueue(): array /** * @inheritdoc */ - public function processQueue() + public function processQueue(): void { if (count($this->actions) > 0) { $this->log->debug('Player Action Service is looking to send %d actions', count($this->actions)); @@ -154,18 +154,23 @@ public function processQueue() if ($this->xmrAddress == null) { $this->xmrAddress = $this->getConfig()->getSetting('XMR_ADDRESS'); } + + $client = new Client($this->config->getGuzzleProxy([ + 'base_uri' => $this->getConfig()->getSetting('XMR_ADDRESS'), + ])); + $failures = 0; + // TODO: could I send them all in one request instead? foreach ($this->actions as $action) { /** @var PlayerAction $action */ try { // Send each action - if ($action->send($this->xmrAddress) === false) { - $this->log->error('Player action refused by XMR (connected but XMR returned false).'); - $failures++; - } - } catch (PlayerActionException $sockEx) { - $this->log->error('Player action connection failed. E = ' . $sockEx->getMessage()); + $client->post('/', [ + 'json' => $action->finaliseMessage(), + ]); + } catch (GuzzleException | PlayerActionException $e) { + $this->log->error('Player action connection failed. E = ' . $e->getMessage()); $failures++; } } diff --git a/lib/Service/PlayerActionServiceInterface.php b/lib/Service/PlayerActionServiceInterface.php index be0f211818..3b99463bd4 100644 --- a/lib/Service/PlayerActionServiceInterface.php +++ b/lib/Service/PlayerActionServiceInterface.php @@ -1,8 +1,8 @@ . */ - - namespace Xibo\Service; - use Xibo\Entity\Display; use Xibo\Support\Exception\GeneralException; use Xibo\XMR\PlayerAction; @@ -36,18 +33,15 @@ interface PlayerActionServiceInterface { /** * PlayerActionHelper constructor. - * @param ConfigServiceInterface - * @param LogServiceInterface - * @param bool */ - public function __construct($config, $log, $triggerPlayerActions); + public function __construct(ConfigServiceInterface $config, LogServiceInterface $log, bool $triggerPlayerActions); /** * @param Display[]|Display $displays * @param PlayerAction $action * @throws GeneralException */ - public function sendAction($displays, $action); + public function sendAction($displays, $action): void; /** * Get the queue @@ -58,5 +52,5 @@ public function getQueue(): array; * Process the Queue of Actions * @throws GeneralException */ - public function processQueue(); -} \ No newline at end of file + public function processQueue(): void; +} diff --git a/lib/XMR/PlayerAction.php b/lib/XMR/PlayerAction.php index 7879e95c31..c678a4a469 100644 --- a/lib/XMR/PlayerAction.php +++ b/lib/XMR/PlayerAction.php @@ -1,6 +1,6 @@ channel = $channel; - $this->publicKey = openssl_get_publickey($key); - if (!$this->publicKey) { - throw new PlayerActionException('Invalid Public Key'); + $this->isEncrypted = $isEncrypted; + + if ($isEncrypted) { + $this->publicKey = openssl_get_publickey($key); + if (!$this->publicKey) { + throw new PlayerActionException('Invalid Public Key'); + } } return $this; @@ -113,7 +121,7 @@ final public function serializeToJson(array $include = []): string * @return array * @throws PlayerActionException */ - final public function getEncryptedMessage(): array + private function getEncryptedMessage(): array { $message = null; @@ -129,79 +137,42 @@ final public function getEncryptedMessage(): array } /** - * Send the action to the specified connection and wait for a reply (acknowledgement) - * @param string $connection - * @return bool - * @throws PlayerActionException + * Finalise the message to be sent + * @throws \Xibo\XMR\PlayerActionException */ - final public function send($connection): bool + final public function finaliseMessage(): array { - try { - // Set the message create date - $this->createdDt = date('c'); + // Set the message create date + $this->createdDt = date('c'); - // Set the TTL if not already set - if (empty($this->ttl)) { - $this->setTtl(); - } + // Set the TTL if not already set + if (empty($this->ttl)) { + $this->setTtl(); + } - // Set the QOS if not already set - if (empty($this->qos)) { - $this->setQos(); - } + // Set the QOS if not already set + if (empty($this->qos)) { + $this->setQos(); + } - // Get the encrypted message - $encrypted = $this->getEncryptedMessage(); + // Envelope our message + $message = [ + 'channel' => $this->channel, + 'qos' => $this->qos, + ]; - // Envelope our message - $message = [ - 'channel' => $this->channel, - 'message' => $encrypted['message'], - 'key' => $encrypted['key'], - 'qos' => $this->qos - ]; - - // Issue a message payload to XMR. - $context = new \ZMQContext(); - - // Connect to socket - $socket = new \ZMQSocket($context, \ZMQ::SOCKET_REQ); - $socket->setSockOpt(\ZMQ::SOCKOPT_LINGER, 2000); - $socket->connect($connection); - - // Send the message to the socket - $socket->send(json_encode($message)); - - // Need to replace this with a non-blocking recv() with a retry loop - $retries = 15; - $reply = false; - - do { - try { - // Try and receive - // if ZMQ::MODE_NOBLOCK/MODE_DONTWAIT is used and the operation would block boolean false - // shall be returned. - $reply = $socket->recv(\ZMQ::MODE_DONTWAIT); - - if ($reply !== false) { - break; - } - } catch (\ZMQSocketException $sockEx) { - if ($sockEx->getCode() !== \ZMQ::ERR_EAGAIN) { - throw $sockEx; - } - } - - usleep(100000); - } while (--$retries); - - // Disconnect socket - $socket->disconnect($connection); - - // Return the reply, if we couldn't connect then the reply will be false - return $reply !== false; - } catch (\ZMQSocketException $ex) { - throw new PlayerActionException('XMR connection failed. Error = ' . $ex->getMessage()); + // Encrypt the message if needed. + if ($this->isEncrypted) { + $encrypted = $this->getEncryptedMessage(); + $message['message'] = $encrypted['message']; + $message['key'] = $encrypted['key']; + $message['isWebSocket'] = false; + } else { + $message['message'] = $this->getMessage(); + $message['key'] = 'none'; + $message['isWebSocket'] = true; } + + return $message; } } diff --git a/lib/XTR/MaintenanceDailyTask.php b/lib/XTR/MaintenanceDailyTask.php index 3ddee40fa6..bb6a88f30c 100644 --- a/lib/XTR/MaintenanceDailyTask.php +++ b/lib/XTR/MaintenanceDailyTask.php @@ -1,6 +1,6 @@ importLayouts(); + // Cycle the XMR Key + $this->cycleXmrKey(); + try { $this->appendRunMessage(__('## Build caches')); @@ -306,4 +312,28 @@ private function cachePlayerBundle(): void $this->appendRunMessage(__('Player bundle cached')); } + + private function cycleXmrKey(): void + { + $this->log->debug('cycleXmrKey: adding new key'); + try { + $key = Random::generateString(20, 'xmr_'); + + $this->getConfig()->changeSetting('XMR_CMS_KEY', $key); + $client = new Client($this->config->getGuzzleProxy([ + 'base_uri' => $this->getConfig()->getSetting('XMR_ADDRESS'), + ])); + + $client->post('/', [ + 'json' => [ + 'id' => constant('SECRET_KEY'), + 'type' => 'keys', + 'key' => $key, + ], + ]); + $this->log->debug('cycleXmrKey: added new key'); + } catch (GuzzleException | \Exception $e) { + $this->log->error('cycleXmrKey: failed. E = ' . $e->getMessage()); + } + } } diff --git a/lib/Xmds/Soap5.php b/lib/Xmds/Soap5.php index 46da9bc793..6da5a776be 100755 --- a/lib/Xmds/Soap5.php +++ b/lib/Xmds/Soap5.php @@ -315,6 +315,11 @@ public function RegisterDisplay( $displayElement->setAttribute('localDate', $dateNow->format(DateFormatHelper::getSystemFormat())); } + // XMR key (this is the key a player should use the intialise a connection to XMR + $node = $return->createElement('xmrCmsKey', $this->getConfig()->getSetting('XMR_CMS_KEY')); + $node->setAttribute('type', 'string'); + $displayElement->appendChild($node); + // Commands $commands = $display->getCommands(); diff --git a/tests/Helper/MockPlayerActionService.php b/tests/Helper/MockPlayerActionService.php index 0481fc7b80..8e6d96c824 100644 --- a/tests/Helper/MockPlayerActionService.php +++ b/tests/Helper/MockPlayerActionService.php @@ -1,8 +1,8 @@ log = $log; } @@ -45,7 +46,7 @@ public function __construct($config, $log, $triggerPlayerActions) /** * @inheritdoc */ - public function sendAction($displays, $action) + public function sendAction($displays, $action): void { $this->log->debug('MockPlayerActionService: sendAction'); @@ -70,7 +71,7 @@ public function getQueue(): array /** * @inheritdoc */ - public function processQueue() + public function processQueue(): void { $this->log->debug('MockPlayerActionService: processQueue'); } diff --git a/tests/Helper/NullPlayerActionService.php b/tests/Helper/NullPlayerActionService.php index 66a968835b..8b3ef1eff1 100644 --- a/tests/Helper/NullPlayerActionService.php +++ b/tests/Helper/NullPlayerActionService.php @@ -1,8 +1,8 @@ log = $log; } @@ -43,7 +44,7 @@ public function __construct($config, $log, $triggerPlayerActions) /** * @inheritdoc */ - public function sendAction($displays, $action) + public function sendAction($displays, $action): void { $this->log->debug('NullPlayerActionService: sendAction'); } @@ -60,7 +61,7 @@ public function getQueue(): array /** * @inheritdoc */ - public function processQueue() + public function processQueue(): void { $this->log->debug('NullPlayerActionService: processQueue'); } diff --git a/views/display-page.twig b/views/display-page.twig index 506271e8ce..4ce3d986d5 100644 --- a/views/display-page.twig +++ b/views/display-page.twig @@ -135,6 +135,7 @@ {% set title %}{% trans "Player Type" %}{% endset %} {% set android %}{% trans "Android" %}{% endset %} + {% set chromeos %}{% trans "Chrome OS" %}{% endset %} {% set windows %}{% trans "Windows" %}{% endset %} {% set webos %}{% trans "webOS" %}{% endset %} {% set sssp %}{% trans "Tizen" %}{% endset %} @@ -142,6 +143,7 @@ {% set options = [ { optionid: "", option: "" }, { optionid: "android", option: android}, + { optionid: "chromeos", option: chromeos}, { optionid: "windows", option: windows}, { optionid: "lg", option: webos}, { optionid: "sssp", option: sssp}, diff --git a/views/settings-page.twig b/views/settings-page.twig index 9b908ae90e..1efa2c7ae6 100644 --- a/views/settings-page.twig +++ b/views/settings-page.twig @@ -297,9 +297,9 @@ {% set helpText %}{% trans "Please enter the private address for XMR." %}{% endset %} {% if theme.isSettingEditable("XMR_ADDRESS") %} - {{ forms.input("XMR_ADDRESS", title, theme.getSetting("XMR_ADDRESS", "tcp:://localhost:5555"), helpText, "required") }} + {{ forms.input("XMR_ADDRESS", title, theme.getSetting("XMR_ADDRESS", "http:://localhost:8081"), helpText, "required") }} {% else %} - {{ forms.disabled("XMR_ADDRESS", title, theme.getSetting("XMR_ADDRESS", "tcp:://localhost:5555"), helpText) }} + {{ forms.disabled("XMR_ADDRESS", title, theme.getSetting("XMR_ADDRESS", "http:://localhost:8081"), helpText) }} {% endif %} {% endif %} From bb7096c34640623d1e88088df3c88413f54941d2 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Tue, 10 Dec 2024 20:33:52 +0000 Subject: [PATCH 07/13] XMR: add a setting for the XMR WebSocket address, pushed to the player. --- lib/Controller/Display.php | 6 ++---- lib/Controller/DisplayProfileConfigFields.php | 17 +++++++++++++++++ lib/Controller/Settings.php | 5 +++++ lib/Factory/DisplayProfileFactory.php | 6 ++++++ lib/Service/PlayerActionService.php | 7 +++++++ lib/Xmds/Soap5.php | 7 +++++++ views/displayprofile-form-edit-android.twig | 6 +++++- views/displayprofile-form-edit-chromeos.twig | 6 +++++- views/displayprofile-form-edit-linux.twig | 4 ++++ views/displayprofile-form-edit-soc.twig | 6 +++++- views/displayprofile-form-edit-windows.twig | 6 +++++- views/settings-page.twig | 11 +++++++++++ 12 files changed, 79 insertions(+), 8 deletions(-) diff --git a/lib/Controller/Display.php b/lib/Controller/Display.php index 06a9a0a1d0..4211f64703 100644 --- a/lib/Controller/Display.php +++ b/lib/Controller/Display.php @@ -2308,7 +2308,7 @@ public function requestScreenShotForm(Request $request, Response $response, $id) * ) * ) */ - public function requestScreenShot(Request $request, Response $response, $id) + public function requestScreenShot(Request $request, Response $response, $id): Response { $display = $this->displayFactory->getById($id); @@ -2320,9 +2320,7 @@ public function requestScreenShot(Request $request, Response $response, $id) $display->screenShotRequested = 1; $display->save(['validate' => false, 'audit' => false]); - $xmrPubAddress = $this->getConfig()->getSetting('XMR_PUB_ADDRESS'); - - if (!empty($display->xmrChannel) && !empty($xmrPubAddress) && $xmrPubAddress !== 'DISABLED') { + if (!empty($display->xmrChannel)) { $this->playerAction->sendAction($display, new ScreenShotAction()); } diff --git a/lib/Controller/DisplayProfileConfigFields.php b/lib/Controller/DisplayProfileConfigFields.php index a0fae05fa3..d0c3ab12eb 100644 --- a/lib/Controller/DisplayProfileConfigFields.php +++ b/lib/Controller/DisplayProfileConfigFields.php @@ -80,6 +80,23 @@ public function editConfigFields($displayProfile, $sanitizedParams, $config = nu $displayProfile->setSetting('xmrNetworkAddress', $sanitizedParams->getString('xmrNetworkAddress'), $ownConfig, $config); } + if ($sanitizedParams->hasParam('xmrWebSocketAddress')) { + $this->handleChangedSettings( + 'xmrWebSocketAddress', + ($ownConfig) + ? $displayProfile->getSetting('xmrWebSocketAddress') + : $display->getSetting('xmrWebSocketAddress'), + $sanitizedParams->getString('xmrWebSocketAddress'), + $changedSettings + ); + $displayProfile->setSetting( + 'xmrWebSocketAddress', + $sanitizedParams->getString('xmrWebSocketAddress'), + $ownConfig, + $config + ); + } + if ($sanitizedParams->hasParam('statsEnabled')) { $this->handleChangedSettings('statsEnabled', ($ownConfig) ? $displayProfile->getSetting('statsEnabled') : $display->getSetting('statsEnabled'), $sanitizedParams->getCheckbox('statsEnabled'), $changedSettings); $displayProfile->setSetting('statsEnabled', $sanitizedParams->getCheckbox('statsEnabled'), $ownConfig, $config); diff --git a/lib/Controller/Settings.php b/lib/Controller/Settings.php index c638d53e0f..5493cf4c1b 100644 --- a/lib/Controller/Settings.php +++ b/lib/Controller/Settings.php @@ -326,6 +326,11 @@ public function update(Request $request, Response $response) $this->getConfig()->changeSetting('XMR_PUB_ADDRESS', $sanitizedParams->getString('XMR_PUB_ADDRESS')); } + if ($this->getConfig()->isSettingEditable('XMR_WS_ADDRESS')) { + $this->handleChangedSettings('XMR_WS_ADDRESS', $this->getConfig()->getSetting('XMR_WS_ADDRESS'), $sanitizedParams->getString('XMR_WS_ADDRESS'), $changedSettings); + $this->getConfig()->changeSetting('XMR_WS_ADDRESS', $sanitizedParams->getString('XMR_WS_ADDRESS'), 1); + } + if ($this->getConfig()->isSettingEditable('DEFAULT_LAT')) { $value = $sanitizedParams->getString('DEFAULT_LAT'); $this->handleChangedSettings('DEFAULT_LAT', $this->getConfig()->getSetting('DEFAULT_LAT'), $value, $changedSettings); diff --git a/lib/Factory/DisplayProfileFactory.php b/lib/Factory/DisplayProfileFactory.php index d912dee6e0..0971e00408 100644 --- a/lib/Factory/DisplayProfileFactory.php +++ b/lib/Factory/DisplayProfileFactory.php @@ -168,6 +168,7 @@ public function loadForType($type) ['name' => 'downloadEndWindow', 'default' => '00:00', 'type' => 'string'], ['name' => 'dayPartId', 'default' => null], ['name' => 'xmrNetworkAddress', 'default' => '', 'type' => 'string'], + ['name' => 'xmrWebSocketAddress', 'default' => '', 'type' => 'string'], [ 'name' => 'statsEnabled', 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0), @@ -220,6 +221,7 @@ public function loadForType($type) ['name' => 'downloadStartWindow', 'default' => '00:00'], ['name' => 'downloadEndWindow', 'default' => '00:00'], ['name' => 'xmrNetworkAddress', 'default' => ''], + ['name' => 'xmrWebSocketAddress', 'default' => ''], [ 'name' => 'statsEnabled', 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0), @@ -276,6 +278,7 @@ public function loadForType($type) ['name' => 'downloadEndWindow', 'default' => '00:00'], ['name' => 'dayPartId', 'default' => null], ['name' => 'xmrNetworkAddress', 'default' => ''], + ['name' => 'xmrWebSocketAddress', 'default' => ''], [ 'name' => 'statsEnabled', 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0), @@ -315,6 +318,7 @@ public function loadForType($type) ['name' => 'downloadEndWindow', 'default' => '00:00'], ['name' => 'dayPartId', 'default' => null], ['name' => 'xmrNetworkAddress', 'default' => ''], + ['name' => 'xmrWebSocketAddress', 'default' => ''], [ 'name' => 'statsEnabled', 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0), @@ -351,6 +355,7 @@ public function loadForType($type) ['name' => 'downloadEndWindow', 'default' => '00:00'], ['name' => 'dayPartId', 'default' => null], ['name' => 'xmrNetworkAddress', 'default' => ''], + ['name' => 'xmrWebSocketAddress', 'default' => ''], [ 'name' => 'statsEnabled', 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0), @@ -384,6 +389,7 @@ public function loadForType($type) ['name' => 'licenceCode', 'default' => ''], ['name' => 'collectInterval', 'default' => 300], ['name' => 'xmrNetworkAddress', 'default' => ''], + ['name' => 'xmrWebSocketAddress', 'default' => ''], [ 'name' => 'statsEnabled', 'default' => (int)$this->config->getSetting('DISPLAY_PROFILE_STATS_DEFAULT', 0), diff --git a/lib/Service/PlayerActionService.php b/lib/Service/PlayerActionService.php index 2849bae3d7..504545f777 100644 --- a/lib/Service/PlayerActionService.php +++ b/lib/Service/PlayerActionService.php @@ -114,6 +114,13 @@ public function sendAction($displays, $action): void } } + // Do not send anything if XMR is disabled. + if (($isEncrypt && $this->getConfig()->getSetting('XMR_WS_ADDRESS') === 'DISABLED') + || (!$isEncrypt && $this->getConfig()->getSetting('XMR_PUB_ADDRESS') === 'DISABLED') + ) { + continue; + } + $displayAction = clone $action; try { diff --git a/lib/Xmds/Soap5.php b/lib/Xmds/Soap5.php index 6da5a776be..010d744eaf 100755 --- a/lib/Xmds/Soap5.php +++ b/lib/Xmds/Soap5.php @@ -187,6 +187,13 @@ public function RegisterDisplay( $arrayItem['value'] = $this->getConfig()->getSetting('XMR_PUB_ADDRESS'); } + // Override the XMR address if empty + if (strtolower($settingName) == 'xmrwebsocketaddress' && + (!isset($arrayItem['value']) || $arrayItem['value'] == '') + ) { + $arrayItem['value'] = $this->getConfig()->getSetting('XMR_WS_ADDRESS'); + } + // logLevels if (strtolower($settingName) == 'loglevel') { // return resting log level diff --git a/views/displayprofile-form-edit-android.twig b/views/displayprofile-form-edit-android.twig index 352a8a7dc6..d49fc9c415 100644 --- a/views/displayprofile-form-edit-android.twig +++ b/views/displayprofile-form-edit-android.twig @@ -74,8 +74,12 @@ ] %} {{ forms.dropdown("collectInterval", "single", title, displayProfile.getSetting("collectInterval"), options, "id", "value", helpText) }} + {% set title = "XMR WebSocket Address"|trans %} + {% set helpText = "Override the CMS WebSocket address for XMR."|trans %} + {{ forms.input("xmrWebSocketAddress", title, displayProfile.getSetting("xmrWebSocketAddress"), helpText) }} + {% set title = "XMR Public Address"|trans %} - {% set helpText = "Please enter the public address for XMR."|trans %} + {% set helpText = "Override the CMS public address for XMR."|trans %} {{ forms.input("xmrNetworkAddress", title, displayProfile.getSetting("xmrNetworkAddress"), helpText) }} {% set title = "Enable stats reporting?"|trans %} diff --git a/views/displayprofile-form-edit-chromeos.twig b/views/displayprofile-form-edit-chromeos.twig index d8a36f912d..0edb3c7d6d 100644 --- a/views/displayprofile-form-edit-chromeos.twig +++ b/views/displayprofile-form-edit-chromeos.twig @@ -67,8 +67,12 @@ ] %} {{ forms.dropdown("collectInterval", "single", title, displayProfile.getSetting("collectInterval"), options, "id", "value", helpText) }} + {% set title = "XMR WebSocket Address"|trans %} + {% set helpText = "Override the CMS WebSocket address for XMR."|trans %} + {{ forms.input("xmrWebSocketAddress", title, displayProfile.getSetting("xmrWebSocketAddress"), helpText) }} + {% set title = "XMR Public Address"|trans %} - {% set helpText = "Please enter the public address for XMR."|trans %} + {% set helpText = "Override the CMS public address for XMR."|trans %} {{ forms.input("xmrNetworkAddress", title, displayProfile.getSetting("xmrNetworkAddress"), helpText) }} {% set title = "Enable stats reporting?"|trans %} diff --git a/views/displayprofile-form-edit-linux.twig b/views/displayprofile-form-edit-linux.twig index fd10545c7d..9e2879044d 100644 --- a/views/displayprofile-form-edit-linux.twig +++ b/views/displayprofile-form-edit-linux.twig @@ -66,6 +66,10 @@ ] %} {{ forms.dropdown("collectInterval", "single", title, displayProfile.getSetting("collectInterval"), options, "id", "value", helpText) }} + {% set title = "XMR WebSocket Address"|trans %} + {% set helpText = "Please enter the WebSocket address for XMR."|trans %} + {{ forms.input("xmrWebSocketAddress", title, displayProfile.getSetting("xmrWebSocketAddress"), helpText) }} + {% set title = "XMR Public Address"|trans %} {% set helpText = "Please enter the public address for XMR."|trans %} {{ forms.input("xmrNetworkAddress", title, displayProfile.getSetting("xmrNetworkAddress"), helpText) }} diff --git a/views/displayprofile-form-edit-soc.twig b/views/displayprofile-form-edit-soc.twig index 80cf5d3464..79950a0007 100644 --- a/views/displayprofile-form-edit-soc.twig +++ b/views/displayprofile-form-edit-soc.twig @@ -70,8 +70,12 @@ ] %} {{ forms.dropdown("collectInterval", "single", title, displayProfile.getSetting("collectInterval"), options, "id", "value", helpText) }} + {% set title = "XMR WebSocket Address"|trans %} + {% set helpText = "Override the CMS WebSocket address for XMR."|trans %} + {{ forms.input("xmrWebSocketAddress", title, displayProfile.getSetting("xmrWebSocketAddress"), helpText) }} + {% set title = "XMR Public Address"|trans %} - {% set helpText = "Please enter the public address for XMR."|trans %} + {% set helpText = "Override the CMS public address for XMR."|trans %} {{ forms.input("xmrNetworkAddress", title, displayProfile.getSetting("xmrNetworkAddress"), helpText) }} {% set title = "Enable stats reporting?"|trans %} diff --git a/views/displayprofile-form-edit-windows.twig b/views/displayprofile-form-edit-windows.twig index 225f9cf52f..527adb7de9 100644 --- a/views/displayprofile-form-edit-windows.twig +++ b/views/displayprofile-form-edit-windows.twig @@ -66,8 +66,12 @@ ] %} {{ forms.dropdown("collectInterval", "single", title, displayProfile.getSetting("collectInterval"), options, "id", "value", helpText) }} + {% set title = "XMR WebSocket Address"|trans %} + {% set helpText = "Override the CMS WebSocket address for XMR."|trans %} + {{ forms.input("xmrWebSocketAddress", title, displayProfile.getSetting("xmrWebSocketAddress"), helpText) }} + {% set title = "XMR Public Address"|trans %} - {% set helpText = "Please enter the public address for XMR."|trans %} + {% set helpText = "Override the CMS public address for XMR."|trans %} {{ forms.input("xmrNetworkAddress", title, displayProfile.getSetting("xmrNetworkAddress"), helpText) }} {% set title = "Enable stats reporting?"|trans %} diff --git a/views/settings-page.twig b/views/settings-page.twig index 1efa2c7ae6..938d05eeca 100644 --- a/views/settings-page.twig +++ b/views/settings-page.twig @@ -303,6 +303,17 @@ {% endif %} {% endif %} + {% if theme.isSettingVisible("XMR_WS_ADDRESS") %} + {% set title %}{% trans "XMR WebSocket Address" %}{% endset %} + {% set helpText %}{% trans "Please enter the WebSocket address for XMR. Leaving this empty will mean the Player app connects to /xmr" %}{% endset %} + + {% if theme.isSettingEditable("XMR_WS_ADDRESS") %} + {{ forms.input("XMR_WS_ADDRESS", title, theme.getSetting("XMR_WS_ADDRESS"), helpText) }} + {% else %} + {{ forms.disabled("XMR_WS_ADDRESS", title, theme.getSetting("XMR_WS_ADDRESS"), helpText) }} + {% endif %} + {% endif %} + {% if theme.isSettingVisible("XMR_PUB_ADDRESS") %} {% set title %}{% trans "XMR Public Address" %}{% endset %} {% set helpText %}{% trans "Please enter the public address for XMR." %}{% endset %} From 45d9805bf38b66fcb6b0890b322a52e771efe8c0 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Tue, 10 Dec 2024 20:49:48 +0000 Subject: [PATCH 08/13] XMR: fix XMR_HOST in apache config. --- docker/etc/apache2/sites-available/000-default.conf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/etc/apache2/sites-available/000-default.conf b/docker/etc/apache2/sites-available/000-default.conf index b11ca2b2f5..564b072e91 100644 --- a/docker/etc/apache2/sites-available/000-default.conf +++ b/docker/etc/apache2/sites-available/000-default.conf @@ -20,6 +20,7 @@ ServerTokens OS PassEnv CMS_USE_MEMCACHED PassEnv MEMCACHED_HOST PassEnv MEMCACHED_PORT + PassEnv XMR_HOST ServerName ${CMS_SERVER_NAME} @@ -46,7 +47,7 @@ ServerTokens OS - ProxyPass ws://xmr:8080 + ProxyPass ws://${XMR_HOST}:8080 ErrorLog /dev/stderr From f749db807f2e398959d79db135951b7b9dcc1f51 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Mon, 16 Dec 2024 14:59:27 +0000 Subject: [PATCH 09/13] XMR: regular maintenance should assert keys --- lib/XTR/MaintenanceDailyTask.php | 5 ++++ lib/XTR/MaintenanceRegularTask.php | 39 +++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/lib/XTR/MaintenanceDailyTask.php b/lib/XTR/MaintenanceDailyTask.php index bb6a88f30c..ff8aae4dd4 100644 --- a/lib/XTR/MaintenanceDailyTask.php +++ b/lib/XTR/MaintenanceDailyTask.php @@ -313,6 +313,11 @@ private function cachePlayerBundle(): void $this->appendRunMessage(__('Player bundle cached')); } + /** + * Once per day we cycle the XMR CMS key + * the old key should remain valid in XMR for up to 1 hour further to allow for cross over + * @return void + */ private function cycleXmrKey(): void { $this->log->debug('cycleXmrKey: adding new key'); diff --git a/lib/XTR/MaintenanceRegularTask.php b/lib/XTR/MaintenanceRegularTask.php index e5ed4e3631..8ed8dcae59 100644 --- a/lib/XTR/MaintenanceRegularTask.php +++ b/lib/XTR/MaintenanceRegularTask.php @@ -19,10 +19,11 @@ * You should have received a copy of the GNU Affero General Public License * along with Xibo. If not, see . */ - - namespace Xibo\XTR; + use Carbon\Carbon; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; use Xibo\Controller\Display; use Xibo\Event\DisplayGroupLoadEvent; use Xibo\Event\MaintenanceRegularEvent; @@ -129,6 +130,8 @@ public function run() $this->tidyAdCampaignSchedules(); + $this->assertXmrKey(); + // Dispatch an event so that consumers can hook into regular maintenance. $event = new MaintenanceRegularEvent(); $this->getDispatcher()->dispatch($event, MaintenanceRegularEvent::$NAME); @@ -615,5 +618,35 @@ private function tidyAdCampaignSchedules() Profiler::end('RegularMaintenance::tidyAdCampaignSchedules', $this->log); $this->runMessage .= ' - Done ' . $count . PHP_EOL . PHP_EOL; } -} + /** + * Once per hour assert the current XMR to push its expiry time with XMR + * this also reseeds the key if XMR restarts + * @return void + */ + private function assertXmrKey(): void + { + $this->log->debug('assertXmrKey: asserting key'); + try { + $key = $this->getConfig()->getSetting('XMR_CMS_KEY'); + if (!empty($key)) { + $client = new Client($this->config->getGuzzleProxy([ + 'base_uri' => $this->getConfig()->getSetting('XMR_ADDRESS'), + ])); + + $client->post('/', [ + 'json' => [ + 'id' => constant('SECRET_KEY'), + 'type' => 'keys', + 'key' => $key, + ], + ]); + $this->log->debug('assertXmrKey: asserted key'); + } else { + $this->log->error('assertXmrKey: key empty'); + } + } catch (GuzzleException | \Exception $e) { + $this->log->error('cycleXmrKey: failed. E = ' . $e->getMessage()); + } + } +} From 218a81b55b000c695ab39404950d049377d1d8fe Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 27 Dec 2024 08:37:09 +0000 Subject: [PATCH 10/13] Errors: fix missing property for InvalidArgument. --- lib/Helper/SanitizerService.php | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/Helper/SanitizerService.php b/lib/Helper/SanitizerService.php index 6d00cb6b12..2a264ddb12 100644 --- a/lib/Helper/SanitizerService.php +++ b/lib/Helper/SanitizerService.php @@ -1,9 +1,23 @@ . */ namespace Xibo\Helper; @@ -25,7 +39,6 @@ public function getSanitizer($array) return (new RespectSanitizer()) ->setCollection($array) ->setDefaultOptions([ - 'throwClass' => '\Xibo\Support\Exception\InvalidArgumentException', 'checkboxReturnInteger' => true ]); } From 4a5a1f4e690a409016fe17e51b075e990130ddfa Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 27 Dec 2024 08:56:51 +0000 Subject: [PATCH 11/13] Fix up after merging develop. --- package-lock.json | 17 +++++------------ package.json | 5 ++--- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 88f212be85..edb419b60c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@mapbox/leaflet-pip": "^1.1.0", "@popperjs/core": "^2.11.8", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", - "@xibosignage/xibo-layout-renderer": "^1.0.4", + "@xibosignage/xibo-layout-renderer": "^1.0.6", "@xiechao/codemirror-lang-handlebars": "^1.0.4", "ajax-bootstrap-select": "^1.4.5", "blueimp-file-upload": "^10.32.0", @@ -76,8 +76,7 @@ "selecto": "^1.26.3", "toastr": "~2.1.4", "underscore": "^1.13.7", - "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#44ad744a5a2a0af9e7fcdb828dbdbeff45cc40f0", - "xibo-layout-renderer": "git+https://github.com/xibosignage/xibo-layout-renderer.git#73b357402c5c483befa868889ce710698c34ffb3" + "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#44ad744a5a2a0af9e7fcdb828dbdbeff45cc40f0" }, "devDependencies": { "@babel/core": "^7.24.9", @@ -3419,9 +3418,9 @@ } }, "node_modules/@xibosignage/xibo-layout-renderer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@xibosignage/xibo-layout-renderer/-/xibo-layout-renderer-1.0.4.tgz", - "integrity": "sha512-kW0dTJWIYA3nOjdgn+Rjw7d+To3jsE1sb0z89A+9ovtLqpVkZHpkhgtXs+j7GcxemAqOH70WASb83KTbHGFBbg==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@xibosignage/xibo-layout-renderer/-/xibo-layout-renderer-1.0.6.tgz", + "integrity": "sha512-fYo1vPmhhRC7TIT8aZjVWyhkQAZ0BseWw9Nk8bWkRFK2s7Iy55O1CGYDs8KeffpMX9eODtgvxZwv12RkpnkFxw==", "license": "LGPL-3.0" }, "node_modules/@xiechao/codemirror-lang-handlebars": { @@ -10223,12 +10222,6 @@ "integrity": "sha512-FHyrngjuc2SFxTmB6gQ/Hp7w5hXtoh0kyDNNltlZ2C9x+q1uX1+VWbEO8P7LEQmx/uf8PNM7QoSi0JwfZeTwkA==", "license": "AGPL-3.0" }, - "node_modules/xibo-layout-renderer": { - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/xibosignage/xibo-layout-renderer.git#73b357402c5c483befa868889ce710698c34ffb3", - "integrity": "sha512-dU+b6nz22NLFVepyt5qZUSZyA0rcWh6tqDs5Guajm0Gyn2AFbLs4OFpR5eEiMjWnGzTl1nIAdALMgMOngQsDGw==", - "license": "LGPL-3.0" - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 79194729ba..8833bdf125 100644 --- a/package.json +++ b/package.json @@ -63,9 +63,9 @@ "@codemirror/state": "^6.4.1", "@fortawesome/fontawesome-free": "^6.6.0", "@mapbox/leaflet-pip": "^1.1.0", - "@xibosignage/xibo-layout-renderer": "^1.0.5", "@popperjs/core": "^2.11.8", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", + "@xibosignage/xibo-layout-renderer": "^1.0.6", "@xiechao/codemirror-lang-handlebars": "^1.0.4", "ajax-bootstrap-select": "^1.4.5", "blueimp-file-upload": "^10.32.0", @@ -124,7 +124,6 @@ "selecto": "^1.26.3", "toastr": "~2.1.4", "underscore": "^1.13.7", - "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#44ad744a5a2a0af9e7fcdb828dbdbeff45cc40f0", - "xibo-layout-renderer": "git+https://github.com/xibosignage/xibo-layout-renderer.git#73b357402c5c483befa868889ce710698c34ffb3" + "xibo-interactive-control": "git+https://github.com/xibosignage/xibo-interactive-control.git#44ad744a5a2a0af9e7fcdb828dbdbeff45cc40f0" } } From c66aec4e233e0de95e309decb080872e561703ae Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 27 Dec 2024 09:51:03 +0000 Subject: [PATCH 12/13] PWA: Tidy up files which are not required. --- .github/workflows/build-container.yaml | 1 - lib/Controller/Pwa.php | 58 +++----------------------- lib/Dependencies/Controllers.php | 2 - web/pwa/index.php | 1 - 4 files changed, 6 insertions(+), 56 deletions(-) diff --git a/.github/workflows/build-container.yaml b/.github/workflows/build-container.yaml index 01d1ef893d..55045d86a0 100644 --- a/.github/workflows/build-container.yaml +++ b/.github/workflows/build-container.yaml @@ -9,7 +9,6 @@ on: - release23 - release33 - release40 - - kopff_chromeos jobs: build: diff --git a/lib/Controller/Pwa.php b/lib/Controller/Pwa.php index 1993069860..2dfb7fad23 100644 --- a/lib/Controller/Pwa.php +++ b/lib/Controller/Pwa.php @@ -26,57 +26,23 @@ use Slim\Http\Response as Response; use Slim\Http\ServerRequest as Request; use Xibo\Factory\DisplayFactory; -use Xibo\Factory\DisplayProfileFactory; -use Xibo\Factory\PlayerVersionFactory; use Xibo\Support\Exception\AccessDeniedException; use Xibo\Support\Exception\GeneralException; use Xibo\Support\Exception\InvalidArgumentException; use Xibo\Xmds\Soap7; +/** + * PWA + * routes for a PWA to download resources which live in an iframe + */ class Pwa extends Base { public function __construct( private readonly DisplayFactory $displayFactory, - private readonly DisplayProfileFactory $displayProfileFactory, - private readonly PlayerVersionFactory $playerVersionFactory, private readonly ContainerInterface $container ) { } - /** - * @throws \Xibo\Support\Exception\NotFoundException - * @throws \Xibo\Support\Exception\AccessDeniedException - */ - public function home(Request $request, Response $response): Response - { - $params = $this->getSanitizer($request->getParams()); - - // See if we have a specific display profile we want to use. - $displayProfileId = $params->getInt('displayProfileId'); - if (!empty($displayProfileId)) { - $displayProfile = $this->displayProfileFactory->getById($displayProfileId); - - if ($displayProfile->type !== 'chromeOS') { - throw new AccessDeniedException(__('This type of display is not allowed to access this API')); - } - } else { - $displayProfile = $this->displayProfileFactory->getDefaultByType('chromeOS'); - } - - // We have the display profile. - // use that to get the player version - $versionId = $displayProfile->getSetting('versionMediaId'); - if (!empty($versionId)) { - $version = $this->playerVersionFactory->getById($versionId); - } else { - $version = $this->playerVersionFactory->getByType('chromeOS'); - } - - // Output the index.html file from the relevant bundle. - $response->getBody()->write(''); - return $response; - } - /** * @throws \Xibo\Support\Exception\InvalidArgumentException * @throws \Psr\Container\NotFoundExceptionInterface @@ -131,13 +97,7 @@ public function getResource(Request $request, Response $response): Response $response->getBody()->write($body); return $response - ->withoutHeader('Content-Security-Policy') - ->withHeader('Access-Control-Allow-Origin', '*') - ->withHeader('Access-Control-Allow-Methods', '*') - ->withHeader( - 'Access-Control-Allow-Headers', - 'append,delete,entries,foreach,get,has,keys,set,values,Origin,Authorization' - ); + ->withoutHeader('Content-Security-Policy'); } catch (\SoapFault $e) { throw new GeneralException($e->getMessage()); } @@ -189,13 +149,7 @@ public function getData(Request $request, Response $response): Response $response->getBody()->write($body); return $response - ->withoutHeader('Content-Security-Policy') - ->withHeader('Access-Control-Allow-Origin', '*') - ->withHeader('Access-Control-Allow-Methods', '*') - ->withHeader( - 'Access-Control-Allow-Headers', - 'append,delete,entries,foreach,get,has,keys,set,values,Origin,Authorization' - ); + ->withoutHeader('Content-Security-Policy'); } catch (\SoapFault $e) { throw new GeneralException($e->getMessage()); } diff --git a/lib/Dependencies/Controllers.php b/lib/Dependencies/Controllers.php index 81ec8b9510..6f0cde89f5 100644 --- a/lib/Dependencies/Controllers.php +++ b/lib/Dependencies/Controllers.php @@ -418,8 +418,6 @@ public static function registerControllersWithDi() '\Xibo\Controller\Pwa' => function (ContainerInterface $c) { $controller = new \Xibo\Controller\Pwa( $c->get('displayFactory'), - $c->get('displayProfileFactory'), - $c->get('playerVersionFactory'), $c, ); $controller->useBaseDependenciesService($c->get('ControllerBaseDependenciesService')); diff --git a/web/pwa/index.php b/web/pwa/index.php index b3fa4189b2..bb30f66e02 100644 --- a/web/pwa/index.php +++ b/web/pwa/index.php @@ -96,7 +96,6 @@ $errorMiddleware->setDefaultErrorHandler(\Xibo\Middleware\Handlers::jsonErrorHandler($container)); // All application routes -$app->get('/', ['\Xibo\Controller\Pwa', 'home'])->setName('pwa.home'); $app->get('/getResource', ['\Xibo\Controller\Pwa', 'getResource'])->setName('pwa.getResource'); $app->get('/getData', ['\Xibo\Controller\Pwa', 'getData'])->setName('pwa.getData'); From bc981a6d1d91c530d55c0efc89684ad069571a65 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 27 Dec 2024 10:12:27 +0000 Subject: [PATCH 13/13] XMR: fix player action service for clientType. --- lib/Service/DisplayNotifyService.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/Service/DisplayNotifyService.php b/lib/Service/DisplayNotifyService.php index 2501fa33e6..77553886ef 100644 --- a/lib/Service/DisplayNotifyService.php +++ b/lib/Service/DisplayNotifyService.php @@ -156,7 +156,9 @@ private function processPlayerActions() $displayIdsRequiringActions = array_values(array_unique($this->displayIdsRequiringActions, SORT_NUMERIC)); $qmarks = str_repeat('?,', count($displayIdsRequiringActions) - 1) . '?'; $displays = $this->store->select( - 'SELECT displayId, xmrChannel, xmrPubKey, display FROM `display` WHERE displayId IN (' . $qmarks . ')', + 'SELECT displayId, xmrChannel, xmrPubKey, display, client_type AS clientType + FROM `display` + WHERE displayId IN (' . $qmarks . ')', $displayIdsRequiringActions ); @@ -166,6 +168,7 @@ private function processPlayerActions() $stdObj->xmrChannel = $display['xmrChannel']; $stdObj->xmrPubKey = $display['xmrPubKey']; $stdObj->display = $display['display']; + $stdObj->clientType = $display['clientType']; try { $this->playerActionService->sendAction($stdObj, new CollectNowAction());