Files
Cloud-CMS/lib/XTR/MaintenanceRegularTask.php

686 lines
26 KiB
PHP
Raw Permalink Normal View History

2025-12-02 10:32:59 -05:00
<?php
/*
* Copyright (C) 2025 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\XTR;
use Carbon\Carbon;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Xibo\Controller\Display;
use Xibo\Event\DisplayGroupLoadEvent;
use Xibo\Event\MaintenanceRegularEvent;
use Xibo\Factory\DisplayFactory;
use Xibo\Factory\DisplayGroupFactory;
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\ModuleFactory;
use Xibo\Factory\NotificationFactory;
use Xibo\Factory\PlaylistFactory;
use Xibo\Factory\ScheduleFactory;
use Xibo\Factory\UserGroupFactory;
use Xibo\Helper\ByteFormatter;
use Xibo\Helper\DateFormatHelper;
use Xibo\Helper\Profiler;
use Xibo\Helper\Status;
use Xibo\Helper\WakeOnLan;
use Xibo\Service\MediaServiceInterface;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\NotFoundException;
/**
* Class MaintenanceRegularTask
* @package Xibo\XTR
*/
class MaintenanceRegularTask implements TaskInterface
{
use TaskTrait;
/** @var Display */
private $displayController;
/** @var MediaServiceInterface */
private $mediaService;
/** @var DisplayFactory */
private $displayFactory;
/** @var DisplayGroupFactory */
private $displayGroupFactory;
/** @var NotificationFactory */
private $notificationFactory;
/** @var UserGroupFactory */
private $userGroupFactory;
/** @var LayoutFactory */
private $layoutFactory;
/** @var PlaylistFactory */
private $playlistFactory;
/** @var ModuleFactory */
private $moduleFactory;
/** @var \Xibo\Helper\SanitizerService */
private $sanitizerService;
/**
* @var ScheduleFactory
*/
private $scheduleFactory;
/** @inheritdoc */
public function setFactories($container)
{
$this->displayController = $container->get('\Xibo\Controller\Display');
$this->mediaService = $container->get('mediaService');
$this->displayFactory = $container->get('displayFactory');
$this->displayGroupFactory = $container->get('displayGroupFactory');
$this->notificationFactory = $container->get('notificationFactory');
$this->userGroupFactory = $container->get('userGroupFactory');
$this->layoutFactory = $container->get('layoutFactory');
$this->playlistFactory = $container->get('playlistFactory');
$this->moduleFactory = $container->get('moduleFactory');
$this->sanitizerService = $container->get('sanitizerService');
$this->scheduleFactory = $container->get('scheduleFactory');
return $this;
}
/** @inheritdoc */
public function run()
{
$this->runMessage = '# ' . __('Regular Maintenance') . PHP_EOL . PHP_EOL;
$this->assertXmrKey();
$this->displayDownEmailAlerts();
$this->licenceSlotValidation();
$this->wakeOnLan();
$this->updatePlaylistDurations();
$this->buildLayouts();
$this->tidyLibrary();
$this->checkLibraryUsage();
$this->checkOverRequestedFiles();
$this->publishLayouts();
$this->assessDynamicDisplayGroups();
$this->tidyAdCampaignSchedules();
$this->tidyUnusedFullScreenLayout();
// Dispatch an event so that consumers can hook into regular maintenance.
$event = new MaintenanceRegularEvent();
$this->getDispatcher()->dispatch($event, MaintenanceRegularEvent::$NAME);
foreach ($event->getMessages() as $message) {
$this->appendRunMessage($message);
}
}
/**
* Display Down email alerts
* - just runs validate displays
*/
private function displayDownEmailAlerts()
{
$this->runMessage .= '## ' . __('Email Alerts') . PHP_EOL;
$this->displayController->validateDisplays($this->displayFactory->query());
$this->appendRunMessage(__('Done'));
}
/**
* Licence Slot Validation
*/
private function licenceSlotValidation()
{
$maxDisplays = $this->config->getSetting('MAX_LICENSED_DISPLAYS');
if ($maxDisplays > 0) {
$this->runMessage .= '## ' . __('Licence Slot Validation') . PHP_EOL;
// Get a list of all displays
try {
$dbh = $this->store->getConnection();
$sth = $dbh->prepare('SELECT displayId, display FROM `display` WHERE licensed = 1 ORDER BY lastAccessed');
$sth->execute();
$displays = $sth->fetchAll(\PDO::FETCH_ASSOC);
if (count($displays) > $maxDisplays) {
// :(
// We need to un-licence some displays
$difference = count($displays) - $maxDisplays;
$this->log->alert(sprintf('Max %d authorised displays exceeded, we need to un-authorise %d of %d displays', $maxDisplays, $difference, count($displays)));
$update = $dbh->prepare('UPDATE `display` SET licensed = 0 WHERE displayId = :displayId');
foreach ($displays as $display) {
$sanitizedDisplay = $this->getSanitizer($display);
// If we are down to 0 difference, then stop
if ($difference == 0) {
break;
}
$this->appendRunMessage(sprintf(__('Disabling %s'), $sanitizedDisplay->getString('display')));
$update->execute(['displayId' => $display['displayId']]);
$this->log->audit('Display', $display['displayId'], 'Regular Maintenance unauthorised display due to max number of slots exceeded.', ['display' => $display['display']]);
$difference--;
}
}
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
catch (\Exception $e) {
$this->log->error($e);
}
}
}
/**
* Wake on LAN
*/
private function wakeOnLan()
{
$this->runMessage = '# ' . __('Wake On LAN') . PHP_EOL;
try {
// Get a list of all displays which have WOL enabled
foreach($this->displayFactory->query(null, ['wakeOnLan' => 1]) as $display) {
/** @var \Xibo\Entity\Display $display */
// Time to WOL (with respect to today)
$timeToWake = strtotime(date('Y-m-d') . ' ' . $display->wakeOnLanTime);
$timeNow = Carbon::now()->format('U');
// Should the display be awake?
if ($timeNow >= $timeToWake) {
// Client should be awake, so has this displays WOL time been passed
if ($display->lastWakeOnLanCommandSent < $timeToWake) {
// Call the Wake On Lan method of the display object
if ($display->macAddress == '' || $display->broadCastAddress == '') {
$this->log->error('This display has no mac address recorded against it yet. Make sure the display is running.');
$this->runMessage .= ' - ' . $display->display . ' Did not send MAC address yet' . PHP_EOL;
continue;
}
$this->log->notice('About to send WOL packet to ' . $display->broadCastAddress . ' with Mac Address ' . $display->macAddress);
try {
WakeOnLan::TransmitWakeOnLan($display->macAddress, $display->secureOn, $display->broadCastAddress, $display->cidr, '9', $this->log);
$this->runMessage .= ' - ' . $display->display . ' Sent WOL Message. Previous WOL send time: ' . Carbon::createFromTimestamp($display->lastWakeOnLanCommandSent)->format(DateFormatHelper::getSystemFormat()) . PHP_EOL;
$display->lastWakeOnLanCommandSent = Carbon::now()->format('U');
$display->save(['validate' => false, 'audit' => true]);
}
catch (\Exception $e) {
$this->runMessage .= ' - ' . $display->display . ' Error=' . $e->getMessage() . PHP_EOL;
}
}
else {
$this->runMessage .= ' - ' . $display->display . ' Display already awake. Previous WOL send time: ' . Carbon::createFromTimestamp($display->lastWakeOnLanCommandSent)->format(DateFormatHelper::getSystemFormat()) . PHP_EOL;
}
}
else {
$this->runMessage .= ' - ' . $display->display . ' Sleeping' . PHP_EOL;
}
}
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
catch (\PDOException $e) {
$this->log->error($e->getMessage());
$this->runMessage .= ' - Error' . PHP_EOL . PHP_EOL;
}
}
/**
* Build layouts
*/
private function buildLayouts()
{
$this->runMessage .= '## ' . __('Build Layouts') . PHP_EOL;
// Build Layouts
// We do not want to build any draft Layouts - they are built in the Layout Designer or on Publish
foreach ($this->layoutFactory->query(null, ['status' => 3, 'showDrafts' => 0, 'disableUserCheck' => 1]) as $layout) {
/* @var \Xibo\Entity\Layout $layout */
try {
$layout = $this->layoutFactory->concurrentRequestLock($layout);
try {
$layout->xlfToDisk(['notify' => true]);
// Commit after each build
// https://github.com/xibosignage/xibo/issues/1593
$this->store->commitIfNecessary();
} finally {
$this->layoutFactory->concurrentRequestRelease($layout);
}
} catch (\Exception $e) {
$this->log->error(sprintf('Maintenance cannot build Layout %d, %s.', $layout->layoutId, $e->getMessage()));
}
}
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
/**
* Tidy library
*/
private function tidyLibrary()
{
$this->runMessage .= '## ' . __('Tidy Library') . PHP_EOL;
// Keep tidy
$this->mediaService->removeExpiredFiles();
$this->mediaService->removeTempFiles();
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
/**
* Check library usage
* @throws \Xibo\Support\Exception\NotFoundException
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
private function checkLibraryUsage()
{
$libraryLimit = $this->config->getSetting('LIBRARY_SIZE_LIMIT_KB') * 1024;
if ($libraryLimit <= 0) {
return;
}
$results = $this->store->select('SELECT IFNULL(SUM(FileSize), 0) AS SumSize FROM media', []);
$sanitizedResults = $this->getSanitizer($results);
$size = $sanitizedResults->getInt('SumSize');
if ($size >= $libraryLimit) {
// Create a notification if we don't already have one today for this display.
$subject = __('Library allowance exceeded');
$date = Carbon::now();
$notifications = $this->notificationFactory->getBySubjectAndDate(
$subject,
$date->startOfDay()->format('U'),
$date->addDay()->startOfDay()->format('U')
);
if (count($notifications) <= 0) {
$body = __(
sprintf(
'Library allowance of %s exceeded. Used %s',
ByteFormatter::format($libraryLimit),
ByteFormatter::format($size)
)
);
$notification = $this->notificationFactory->createSystemNotification(
$subject,
$body,
Carbon::now(),
'library'
);
$notification->save();
$this->log->critical($subject);
}
}
}
/**
* Checks to see if there are any overrequested files.
* @throws \Xibo\Support\Exception\NotFoundException
* @throws \Xibo\Support\Exception\InvalidArgumentException
*/
private function checkOverRequestedFiles()
{
$items = $this->store->select('
SELECT display.displayId,
display.display,
COUNT(*) AS countFiles
FROM `requiredfile`
INNER JOIN `display`
ON display.displayId = requiredfile.displayId
WHERE `bytesRequested` > 0
AND `requiredfile`.bytesRequested >= `requiredfile`.`size` * :factor
AND `requiredfile`.type NOT IN (\'W\', \'D\')
AND display.lastAccessed > :lastAccessed
AND `requiredfile`.complete = 0
GROUP BY display.displayId, display.display
', [
'factor' => 3,
'lastAccessed' => Carbon::now()->subDay()->format('U'),
]);
foreach ($items as $item) {
$sanitizedItem = $this->getSanitizer($item);
// Create a notification if we don't already have one today for this display.
$subject = sprintf(
__('%s is downloading %d files too many times'),
$sanitizedItem->getString('display'),
$sanitizedItem->getInt('countFiles')
);
$date = Carbon::now();
$notifications = $this->notificationFactory->getBySubjectAndDate(
$subject,
$date->startOfDay()->format('U'),
$date->addDay()->startOfDay()->format('U')
);
if (count($notifications) <= 0) {
$body = sprintf(
__('Please check the bandwidth graphs and display status for %s to investigate the issue.'),
$sanitizedItem->getString('display')
);
$notification = $this->notificationFactory->createSystemNotification(
$subject,
$body,
Carbon::now(),
'display'
);
$display = $this->displayFactory->getById($item['displayId']);
// Add in any displayNotificationGroups, with permissions
foreach ($this->userGroupFactory->getDisplayNotificationGroups($display->displayGroupId) as $group) {
$notification->assignUserGroup($group);
}
$notification->save();
$this->log->critical($subject);
}
}
}
/**
* Update Playlist Durations
*/
private function updatePlaylistDurations()
{
$this->runMessage .= '## ' . __('Playlist Duration Updates') . PHP_EOL;
// Build Layouts
foreach ($this->playlistFactory->query(null, ['requiresDurationUpdate' => 1]) as $playlist) {
try {
$playlist->setModuleFactory($this->moduleFactory);
$playlist->updateDuration();
} catch (GeneralException $xiboException) {
$this->log->error(
'Maintenance cannot update Playlist ' . $playlist->playlistId .
', ' . $xiboException->getMessage()
);
}
}
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
/**
* Publish layouts with set publishedDate
* @throws GeneralException
*/
private function publishLayouts()
{
$this->runMessage .= '## ' . __('Publishing layouts with set publish dates') . PHP_EOL;
$layouts = $this->layoutFactory->query(
null,
['havePublishDate' => 1, 'disableUserCheck' => 1, 'excludeTemplates' => -1]
);
// check if we have any layouts with set publish date
if (count($layouts) > 0) {
foreach ($layouts as $layout) {
// check if the layout should be published now according to the date
if (Carbon::createFromFormat(DateFormatHelper::getSystemFormat(), $layout->publishedDate)
->isBefore(Carbon::now()->format(DateFormatHelper::getSystemFormat()))
) {
try {
// publish the layout
$layout = $this->layoutFactory->concurrentRequestLock($layout, true);
try {
$draft = $this->layoutFactory->getByParentId($layout->layoutId);
if ($draft->status === Status::$STATUS_INVALID
&& isset($draft->statusMessage)
&& (
count($draft->getStatusMessage()) > 1 ||
count($draft->getStatusMessage()) === 1 &&
!$draft->checkForEmptyRegion()
)
) {
throw new GeneralException(json_encode($draft->statusMessage));
}
$draft->publishDraft();
$draft->load();
$draft->xlfToDisk([
'notify' => true,
'exceptionOnError' => true,
'exceptionOnEmptyRegion' => false
]);
} finally {
$this->layoutFactory->concurrentRequestRelease($layout, true);
}
$this->log->info(
'Published layout ID ' . $layout->layoutId . ' new layout id is ' . $draft->layoutId
);
} catch (GeneralException $e) {
$this->log->error(
'Error publishing layout ID ' . $layout->layoutId .
' with name ' . $layout->layout . ' Failed with message: ' . $e->getMessage()
);
// create a notification
$subject = __(sprintf('Error publishing layout ID %d', $layout->layoutId));
$date = Carbon::now();
$notifications = $this->notificationFactory->getBySubjectAndDate(
$subject,
$date->startOfDay()->format('U'),
$date->addDay()->startOfDay()->format('U')
);
if (count($notifications) <= 0) {
$body = __(
sprintf(
'Publishing layout ID %d with name %s failed. With message %s',
$layout->layoutId,
$layout->layout,
$e->getMessage()
)
);
$notification = $this->notificationFactory->createSystemNotification(
$subject,
$body,
Carbon::now(),
'layout'
);
$notification->save();
$this->log->critical($subject);
}
}
} else {
$this->log->debug(
'Layouts with published date were found, they are set to publish later than current time'
);
}
}
} else {
$this->log->debug('No layouts to publish.');
}
$this->runMessage .= ' - Done' . PHP_EOL . PHP_EOL;
}
/**
* Assess any eligible dynamic display groups if necessary
* @return void
* @throws \Xibo\Support\Exception\NotFoundException
*/
private function assessDynamicDisplayGroups(): void
{
$this->runMessage .= '## ' . __('Assess Dynamic Display Groups') . PHP_EOL;
// Do we have a cache key set to say that dynamic display group assessment has been completed?
$cache = $this->pool->getItem('DYNAMIC_DISPLAY_GROUP_ASSESSED');
if ($cache->isMiss()) {
Profiler::start('RegularMaintenance::assessDynamicDisplayGroups', $this->log);
// Set the cache key with a long expiry and save.
$cache->set(true);
$cache->expiresAt(Carbon::now()->addYear());
$this->pool->save($cache);
// Process each dynamic display group
$count = 0;
foreach ($this->displayGroupFactory->getByIsDynamic(1) as $group) {
$count++;
try {
// Loads displays.
$this->getDispatcher()->dispatch(
new DisplayGroupLoadEvent($group),
DisplayGroupLoadEvent::$NAME
);
$group->save([
'validate' => false,
'saveGroup' => false,
'saveTags' => false,
'manageLinks' => false,
'manageDisplayLinks' => false,
'manageDynamicDisplayLinks' => true,
'allowNotify' => true
]);
} catch (GeneralException $exception) {
$this->log->error('assessDynamicDisplayGroups: Unable to manage group: '
. $group->displayGroup);
}
}
Profiler::end('RegularMaintenance::assessDynamicDisplayGroups', $this->log);
$this->runMessage .= ' - Done ' . $count . PHP_EOL . PHP_EOL;
} else {
$this->runMessage .= ' - Done (not required)' . PHP_EOL . PHP_EOL;
}
}
private function tidyAdCampaignSchedules()
{
$this->runMessage .= '## ' . __('Tidy Ad Campaign Schedules') . PHP_EOL;
Profiler::start('RegularMaintenance::tidyAdCampaignSchedules', $this->log);
$count = 0;
foreach ($this->scheduleFactory->query(null, [
'adCampaignsOnly' => 1,
'toDt' => Carbon::now()->subDays(90)->unix()
]) as $event) {
if (!empty($event->parentCampaignId)) {
$count++;
$this->log->debug('tidyAdCampaignSchedules : Found old Ad Campaign interrupt event ID '
. $event->eventId . ' deleting');
$event->delete(['notify' => false]);
}
}
$this->log->debug('tidyAdCampaignSchedules : Deleted ' . $count . ' events');
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('assertXmrKey: failed. E = ' . $e->getMessage());
}
}
/**
* Deletes unused full screen layouts
* @throws NotFoundException
* @throws GeneralException
*/
private function tidyUnusedFullScreenLayout(): void
{
$this->runMessage .= '## ' . __('Tidy Unused FullScreen Layout') . PHP_EOL;
Profiler::start('RegularMaintenance::tidyUnusedFullScreenLayout', $this->log);
$count = 0;
foreach ($this->layoutFactory->query(null, [
'filterLayoutStatusId' => 3,
'isFullScreenCampaign' => 1
]) as $layout) {
$count++;
$this->log->debug('tidyUnusedFullScreenLayout : Found unused fullscreen layout ID '
. $layout->layoutId . ' deleting');
$layout->delete();
}
$this->log->debug('tidyUnusedFullScreenLayout : Deleted ' . $count . ' layouts');
Profiler::end('RegularMaintenance::tidyUnusedFullScreenLayout', $this->log);
$this->runMessage .= ' - Done ' . $count . PHP_EOL . PHP_EOL;
}
}