From 8e07966306230a0fc3bea425c5400f6c74509cb7 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Tue, 8 Oct 2024 15:50:17 +0100 Subject: [PATCH] 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());