diff --git a/src/Cli/CommandInterface.php b/src/Cli/CommandInterface.php index e443e87e..3865d64f 100644 --- a/src/Cli/CommandInterface.php +++ b/src/Cli/CommandInterface.php @@ -16,7 +16,6 @@ namespace XcVm\Cli; */ interface CommandInterface { - /** * Уникальное имя команды. * diff --git a/src/Cli/CommandRegistry.php b/src/Cli/CommandRegistry.php index bda03752..9a200c89 100644 --- a/src/Cli/CommandRegistry.php +++ b/src/Cli/CommandRegistry.php @@ -20,7 +20,6 @@ namespace XcVm\Cli; */ class CommandRegistry { - /** @var CommandInterface[] name → command */ private $rCommands = []; diff --git a/src/Cli/Commands/ArchiveCommand.php b/src/Cli/Commands/ArchiveCommand.php index 695df241..c08727b3 100644 --- a/src/Cli/Commands/ArchiveCommand.php +++ b/src/Cli/Commands/ArchiveCommand.php @@ -25,7 +25,6 @@ use XcVm\Domain\Stream\StreamProcess; */ class ArchiveCommand implements CommandInterface { - /** @inheritDoc */ public function getName(): string { return 'archive'; @@ -111,7 +110,7 @@ class ArchiveCommand implements CommandInterface { // currently listed in the playlist to avoid reading a partial file. $rPlaylistContent = @file_get_contents($rPlaylist); preg_match_all('/_(\d+)\.ts/', ($rPlaylistContent ?: ''), $rSegMatches); - $rSegNum = !empty($rSegMatches[1]) ? (int)end($rSegMatches[1]) + 1 : 0; + $rSegNum = !empty($rSegMatches[1]) ? (int) end($rSegMatches[1]) + 1 : 0; $this->logStatus('Starting from source segment #' . $rSegNum . '.'); // Open the first archive minute file; subsequent ones are opened on rotation. @@ -165,7 +164,7 @@ class ArchiveCommand implements CommandInterface { if ($rWaitCount % 300 === 0) { $rFreshContent = @file_get_contents($rPlaylist); preg_match_all('/_(\d+)\.ts/', ($rFreshContent ?: ''), $rFreshMatches); - $rFreshSeg = !empty($rFreshMatches[1]) ? (int)end($rFreshMatches[1]) : -1; + $rFreshSeg = !empty($rFreshMatches[1]) ? (int) end($rFreshMatches[1]) : -1; if ($rFreshSeg >= 0 && $rFreshSeg > $rSegNum + 1) { // Forward gap: ffmpeg skipped segment numbers (old files purged @@ -269,7 +268,7 @@ class ArchiveCommand implements CommandInterface { * * @param string $rMessage Human-readable status message. */ - private function logStatus($rMessage): void { + private function logStatus(string $rMessage): void { echo '[archive][' . getmypid() . '][' . gmdate('Y-m-d H:i:s') . '] ' . $rMessage . "\n"; } @@ -283,7 +282,7 @@ class ArchiveCommand implements CommandInterface { * @param int $rStreamID Stream identifier used to build the archive path. * @param int $rDuration Maximum retention duration in days. */ - private function deleteSegments($rStreamID, $rDuration): void { + private function deleteSegments(int $rStreamID, int $rDuration): void { $rSegmentCount = intval(count(scandir(ARCHIVE_PATH . $rStreamID . '/')) - 2); if ($rDuration * 24 * 60 < $rSegmentCount) { $rDelta = $rSegmentCount - $rDuration * 24 * 60; @@ -308,7 +307,7 @@ class ArchiveCommand implements CommandInterface { * * @param int $rStreamID Stream identifier. */ - private function checkRunning($rStreamID): void { + private function checkRunning(int $rStreamID): void { clearstatcache(true); $rPID = null; if (file_exists(STREAMS_PATH . $rStreamID . '_.archive')) { @@ -326,7 +325,7 @@ class ArchiveCommand implements CommandInterface { file_put_contents(STREAMS_PATH . $rStreamID . '_.archive', getmypid()); $this->logStatus('Wrote current PID to archive marker file.'); } - + /** * Checks whether a given PID is an active TVArchive worker for this stream. * @@ -337,7 +336,7 @@ class ArchiveCommand implements CommandInterface { * @param int $rStreamID Stream identifier to match against the process title. * @return bool True if the process is an archive worker for this stream. */ - private function isArchiveProcessForStream($rPID, $rStreamID): bool { + private function isArchiveProcessForStream(int $rPID, int $rStreamID): bool { if (!is_numeric($rPID) || 0 >= intval($rPID) || !file_exists('/proc/' . $rPID)) { return false; } diff --git a/src/Cli/Commands/BinariesCommand.php b/src/Cli/Commands/BinariesCommand.php index 63e79518..065f9afb 100644 --- a/src/Cli/Commands/BinariesCommand.php +++ b/src/Cli/Commands/BinariesCommand.php @@ -17,7 +17,6 @@ use XcVm\Core\Updates\UpdateChannels; */ class BinariesCommand implements CommandInterface { - public function getName(): string { return 'binaries'; } @@ -101,7 +100,7 @@ class BinariesCommand implements CommandInterface { $rLogFile = $rLogDir . 'binaries_update_' . date('Ymd_His') . '.log'; $rDetachedCommand = 'nohup ' . $rCommand . ' > ' . escapeshellarg($rLogFile) . ' 2>&1 < /dev/null & echo $!'; echo "Starting binaries updater in background...\n"; - $rPid = trim((string)shell_exec($rDetachedCommand)); + $rPid = trim((string) shell_exec($rDetachedCommand)); if (empty($rPid)) { echo "Failed to start binaries updater in background.\n"; diff --git a/src/Cli/Commands/CacheHandlerCommand.php b/src/Cli/Commands/CacheHandlerCommand.php index d950d896..b374abf1 100644 --- a/src/Cli/Commands/CacheHandlerCommand.php +++ b/src/Cli/Commands/CacheHandlerCommand.php @@ -73,7 +73,7 @@ class CacheHandlerCommand implements CommandInterface { } try { - $rUpdatedLines = array(); + $rUpdatedLines = []; foreach (SignalQueue::pending() as list($rFileMD5, $rKey, $rData)) { list($rHeader) = explode('/', $rKey); switch ($rHeader) { diff --git a/src/Cli/Commands/CertbotCommand.php b/src/Cli/Commands/CertbotCommand.php index 4d6dfa11..5253b005 100644 --- a/src/Cli/Commands/CertbotCommand.php +++ b/src/Cli/Commands/CertbotCommand.php @@ -17,7 +17,6 @@ use XcVm\Domain\Server\ServerRepository; */ class CertbotCommand implements CommandInterface { - public function getName(): string { return 'certbot'; } @@ -50,26 +49,26 @@ class CertbotCommand implements CommandInterface { if (file_exists(BIN_PATH . 'certbot/logs/xc_vm.log')) { unlink(BIN_PATH . 'certbot/logs/xc_vm.log'); } - foreach (array('logs', 'config', 'work') as $rPath) { + foreach (['logs', 'config', 'work'] as $rPath) { if (file_exists(BIN_PATH . 'certbot/' . $rPath . '/.certbot.lock')) { unlink(BIN_PATH . 'certbot/' . $rPath . '/.certbot.lock'); } } - $rActiveDomains = array(); + $rActiveDomains = []; foreach ($rData['domain'] as $rDomain) { if (!empty($rDomain) && !filter_var($rDomain, FILTER_VALIDATE_IP)) { $rActiveDomains[] = $rDomain; } } $rError = null; - $rOutput = array(); + $rOutput = []; $rResult = false; if (0 < count($rActiveDomains)) { $rCertbotWebroot = MAIN_HOME . 'certbot-webroot'; if (!is_dir($rCertbotWebroot)) { mkdir($rCertbotWebroot, 0775, true); } - foreach (array('--dry-run ', '') as $rDry) { + foreach (['--dry-run ', ''] as $rDry) { if (ServerRepository::getAll()[SERVER_ID]['http_broadcast_port'] == 80) { $rCommand = 'sudo certbot ' . $rDry . '--config-dir ' . BIN_PATH . 'certbot/config --work-dir ' . BIN_PATH . 'certbot/work --logs-dir ' . BIN_PATH . 'certbot/logs certonly --agree-tos --expand --non-interactive --register-unsafely-without-email --webroot -w ' . $rCertbotWebroot; } else { @@ -79,7 +78,7 @@ class CertbotCommand implements CommandInterface { $rCommand .= ' -d ' . basename($rDomain); } $rCommand .= ' 2>&1'; - $rOutput = array(); + $rOutput = []; exec($rCommand, $rOutput, $rReturn); if (empty($rDry)) { @@ -135,11 +134,11 @@ class CertbotCommand implements CommandInterface { } else { $rError = 3; } - if (in_array($rError, array(0, 1))) { + if (in_array($rError, [0, 1])) { $db->query('SELECT `certbot_ssl` FROM `servers` WHERE `id` = ?;', SERVER_ID); $rCertInfo = json_decode($db->get_row()['certbot_ssl'], true); if (!$rCertInfo) { - $rSelectedDomain = array(null, null); + $rSelectedDomain = [null, null]; foreach (scandir(BIN_PATH . 'certbot/config/live/') as $rDir) { if ($rDir != '.' && $rDir != '..') { $rSplit = explode('-', $rDir); @@ -151,7 +150,7 @@ class CertbotCommand implements CommandInterface { if (in_array(strtolower($rDomain), array_map('strtolower', $rActiveDomains))) { $rInfo = DiagnosticsService::getCertificateInfo(BIN_PATH . 'certbot/config/live/' . $rDir . '/fullchain.pem'); if (($rInfo['serial'] && $rSelectedDomain[0] < $rInfo['expiration']) && !$rSelectedDomain[0]) { - $rSelectedDomain = array($rInfo['expiration'], $rInfo); + $rSelectedDomain = [$rInfo['expiration'], $rInfo]; } } } @@ -171,7 +170,7 @@ class CertbotCommand implements CommandInterface { } } } - $rReturn = array('status' => $rResult, 'error' => $rError, 'output' => $rOutput); + $rReturn = ['status' => $rResult, 'error' => $rError, 'output' => $rOutput]; if (!is_dir(BIN_PATH . 'certbot/logs')) { mkdir(BIN_PATH . 'certbot/logs', 0775, true); } diff --git a/src/Cli/Commands/CreatedCommand.php b/src/Cli/Commands/CreatedCommand.php index 80fb6326..71d0387b 100644 --- a/src/Cli/Commands/CreatedCommand.php +++ b/src/Cli/Commands/CreatedCommand.php @@ -20,7 +20,6 @@ use XcVm\Streaming\Codec\FFprobeRunner; */ class CreatedCommand implements CommandInterface { - public function getName(): string { return 'created'; } @@ -71,7 +70,7 @@ class CreatedCommand implements CommandInterface { $rServerInfo['cchannel_rsources'] = json_decode($rServerInfo['cchannel_rsources'], true); if (!$rServerInfo['cchannel_rsources']) { - $rServerInfo['cchannel_rsources'] = array(); + $rServerInfo['cchannel_rsources'] = []; } $rSourcesLeft = array_diff($rStreamInfo['stream_source'], $rServerInfo['cchannel_rsources']); @@ -133,13 +132,14 @@ class CreatedCommand implements CommandInterface { . (isset($rEncode['speed']) ? ' @ ' . $rEncode['speed'] : '') . "\n"; $db->db_connect(); - $db->query('UPDATE `streams_servers` SET `progress_info` = ? WHERE `server_stream_id` = ?', json_encode(array('cc_encode' => array( + $db->query('UPDATE `streams_servers` SET `progress_info` = ? WHERE `server_stream_id` = ?', json_encode(['cc_encode' => [ 'source' => $rDone + 1, 'total' => $rTotal, 'pct' => $rPct, 'out_time' => gmdate('H:i:s', (int) $rOutSecs), 'speed' => ($rEncode['speed'] ?? null), - ))), $rServerInfo['server_stream_id']); + ] + ]), $rServerInfo['server_stream_id']); $db->close_mysql(); } } @@ -187,17 +187,19 @@ class CreatedCommand implements CommandInterface { $rInt = $rSeconds = 0; $rList = explode("\n", file_get_contents(CREATED_PATH . $rStreamID . '_.list')); - $rReturn = array(); + $rReturn = []; foreach ($rList as $rItem) { $parts = explode("'", $rItem); - if (!isset($parts[1])) continue; + if (!isset($parts[1])) { + continue; + } $rFilename = $parts[1]; if (file_exists($rFilename)) { $rFileInfo = FFprobeRunner::probeStream($rFilename); - $rReturn[] = array( + $rReturn[] = [ 'position' => $rInt, 'filename' => basename($rFilename), 'path' => $rFilename, @@ -205,7 +207,7 @@ class CreatedCommand implements CommandInterface { 'seconds' => $rFileInfo['of_duration'], 'start' => $rSeconds, 'finish' => $rSeconds + $rFileInfo['of_duration'] - ); + ]; $rSeconds += $rFileInfo['of_duration']; $rInt++; @@ -227,12 +229,12 @@ class CreatedCommand implements CommandInterface { */ private function readEncodeProgress(string $rFile): array { if (!is_file($rFile)) { - return array(); + return []; } $rHandle = @fopen($rFile, 'r'); if (!$rHandle) { - return array(); + return []; } if (filesize($rFile) > 4096) { fseek($rHandle, -4096, SEEK_END); @@ -240,7 +242,7 @@ class CreatedCommand implements CommandInterface { $rTail = stream_get_contents($rHandle); fclose($rHandle); - $rOutput = array(); + $rOutput = []; foreach (array_filter(array_map('trim', explode("\n", (string) $rTail))) as $rRow) { $rParts = explode('=', $rRow, 2); if (count($rParts) == 2) { diff --git a/src/Cli/Commands/DbMigrateCommand.php b/src/Cli/Commands/DbMigrateCommand.php index 565e8f8f..75551bd3 100644 --- a/src/Cli/Commands/DbMigrateCommand.php +++ b/src/Cli/Commands/DbMigrateCommand.php @@ -16,7 +16,6 @@ use XcVm\Core\Database\MigrationRunner; */ class DbMigrateCommand implements CommandInterface { - public function getName(): string { return 'db:migrate'; } diff --git a/src/Cli/Commands/DelayCommand.php b/src/Cli/Commands/DelayCommand.php index 88b4a1e9..56ae8b08 100644 --- a/src/Cli/Commands/DelayCommand.php +++ b/src/Cli/Commands/DelayCommand.php @@ -19,7 +19,6 @@ use XcVm\Streaming\Fanout\IngestFeeder; */ class DelayCommand implements CommandInterface { - public function getName(): string { return 'delay'; } @@ -72,9 +71,9 @@ class DelayCommand implements CommandInterface { $db->close_mysql(); $rDelayDuration = intval($rStreamInfo['delay_minutes']) + 5; $this->cleanUpSegments($rStreamID, $rDelayDuration); - $rSegmentSettings = array('seg_time' => intval(SettingsManager::get('seg_time')), 'seg_list_size' => intval(SettingsManager::get('seg_list_size')), 'seg_delete_threshold' => intval(SettingsManager::get('seg_delete_threshold'))); + $rSegmentSettings = ['seg_time' => intval(SettingsManager::get('seg_time')), 'seg_list_size' => intval(SettingsManager::get('seg_list_size')), 'seg_delete_threshold' => intval(SettingsManager::get('seg_delete_threshold'))]; $rTotalSegments = intval($rSegmentSettings['seg_list_size']) + 5; - $rOldSegments = array(); + $rOldSegments = []; if (file_exists($rPlaylistOld)) { $rOldSegments = $this->getSegments($rPlaylistOld, -1); } @@ -89,7 +88,7 @@ class DelayCommand implements CommandInterface { }); $rFeeder->connect(); $rFedSegment = null; - $rFeedQueue = array(); + $rFeedQueue = []; $rFeedCurrent = null; $rPrevMD5 = null; @@ -102,7 +101,7 @@ class DelayCommand implements CommandInterface { $rSegmentSettings['seg_time'] = $rDuration; } } - $rM3U8 = array('vars' => array('#EXTM3U' => '', '#EXT-X-VERSION' => 3, '#EXT-X-MEDIA-SEQUENCE' => '0', '#EXT-X-TARGETDURATION' => $rSegmentSettings['seg_time']), 'segments' => $this->getData($rPlaylistDelay, $rOldSegments, $rTotalSegments, $rPlaylistOld)); + $rM3U8 = ['vars' => ['#EXTM3U' => '', '#EXT-X-VERSION' => 3, '#EXT-X-MEDIA-SEQUENCE' => '0', '#EXT-X-TARGETDURATION' => $rSegmentSettings['seg_time']], 'segments' => $this->getData($rPlaylistDelay, $rOldSegments, $rTotalSegments, $rPlaylistOld)]; if (!empty($rM3U8['segments'])) { $rData = ''; $rSequence = 0; @@ -152,7 +151,7 @@ class DelayCommand implements CommandInterface { * @return void */ private function queueForDaemon(array $rSegments, ?int &$rFedSegment, array &$rQueue): void { - $rNew = array(); + $rNew = []; foreach ($rSegments as $rSegment) { if (preg_match('/_(\d+)\.ts$/', (string) ($rSegment['file'] ?? ''), $rMatch)) { $rNumber = intval($rMatch[1]); @@ -176,7 +175,7 @@ class DelayCommand implements CommandInterface { } if (is_string($rData) && strlen($rData) >= 188) { $rData = substr($rData, 0, strlen($rData) - strlen($rData) % 188); // whole packets - $rQueue[] = array('data' => $rData, 'dur' => max(0.5, floatval($rSegment['seconds'])), 'burst' => $rSeed); + $rQueue[] = ['data' => $rData, 'dur' => max(0.5, floatval($rSegment['seconds'])), 'burst' => $rSeed]; } $rFedSegment = $rNumber; } @@ -196,7 +195,7 @@ class DelayCommand implements CommandInterface { $rNow = microtime(true); if ($rCurrent === null && count($rQueue) > 0) { $rItem = array_shift($rQueue); - $rCurrent = array('data' => $rItem['data'], 'sent' => 0, 'start' => $rNow, 'dur' => $rItem['dur'], 'burst' => $rItem['burst']); + $rCurrent = ['data' => $rItem['data'], 'sent' => 0, 'start' => $rNow, 'dur' => $rItem['dur'], 'burst' => $rItem['burst']]; } if ($rCurrent === null) { $rFeeder->flush(); @@ -236,7 +235,7 @@ class DelayCommand implements CommandInterface { } private function getData($rPlaylistDelay, &$rOldSegments, $rTotalSegments, $rPlaylistOld): array { - $rSegments = array(); + $rSegments = []; if (!empty($rOldSegments)) { $rSegments = array_shift($rOldSegments); unlink(DELAY_PATH . $rSegments['file']); @@ -268,7 +267,7 @@ class DelayCommand implements CommandInterface { } private function getSegments($rPlaylist, $rCounter = 0): array { - $rSegments = array(); + $rSegments = []; if (file_exists($rPlaylist)) { $rFP = fopen($rPlaylist, 'r'); while (!feof($rFP) && count($rSegments) != $rCounter) { @@ -278,7 +277,7 @@ class DelayCommand implements CommandInterface { $rSeconds = rtrim($rSeconds, ','); $rSegmentFile = trim(fgets($rFP)); if (file_exists(DELAY_PATH . $rSegmentFile)) { - $rSegments[] = array('seconds' => $rSeconds, 'file' => $rSegmentFile); + $rSegments[] = ['seconds' => $rSeconds, 'file' => $rSegmentFile]; } } } diff --git a/src/Cli/Commands/FanoutSyncCommand.php b/src/Cli/Commands/FanoutSyncCommand.php index 02183c50..02de2575 100644 --- a/src/Cli/Commands/FanoutSyncCommand.php +++ b/src/Cli/Commands/FanoutSyncCommand.php @@ -41,7 +41,7 @@ class FanoutSyncCommand implements CommandInterface { private const INTERVAL = 10; /** @var array Daemon viewer uuid => when it was first seen without a row. */ - private array $rOrphanSince = array(); + private array $rOrphanSince = []; public function getName(): string { return 'fanout_sync'; @@ -151,7 +151,7 @@ class FanoutSyncCommand implements CommandInterface { * @return string[] The uuids dropped on this pass. */ public function dropOrphans(array $rActive, array $rConns, ?int $rNow = null, ?callable $rDrop = null): array { - $rKnown = array(); + $rKnown = []; foreach ($rConns as $rConn) { if (is_array($rConn) && !empty($rConn['uuid']) && empty($rConn['hls_end'])) { $rKnown[$rConn['uuid']] = true; @@ -162,8 +162,8 @@ class FanoutSyncCommand implements CommandInterface { $rDrop = $rDrop ?? static function (string $rUUID): void { FanoutClient::dropConnection($rUUID); }; - $rSeen = array(); - $rDropped = array(); + $rSeen = []; + $rDropped = []; foreach ($rActive as $rUUID) { $rUUID = (string) $rUUID; if ($rUUID === '' || isset($rKnown[$rUUID])) { @@ -290,7 +290,7 @@ class FanoutSyncCommand implements CommandInterface { if (!is_array($rKeys)) { return null; } - $rOut = array(); + $rOut = []; foreach ($rKeys as $rUUID) { $rConn = ConnectionTracker::getConnection($rUUID); if (is_array($rConn)) { diff --git a/src/Cli/Commands/LbInstallFlow.php b/src/Cli/Commands/LbInstallFlow.php index a4546d5b..da31f8e3 100644 --- a/src/Cli/Commands/LbInstallFlow.php +++ b/src/Cli/Commands/LbInstallFlow.php @@ -7,19 +7,18 @@ use XcVm\Core\Updates\GitHubReleases; use XcVm\Core\Updates\UpdateChannels; class LbInstallFlow { - // Per-distribution package lists, mirrored from the MAIN installer (install -> PACKAGES), // with the mariadb-server/client/common packages removed (LB nodes use the main server's DB). public static function getPackages(string $rDistID = 'debian', string $rVersion = ''): array { - $rLists = array( - 'debian' => array('iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'libcurl4', 'libgeoip-dev', 'libxslt1-dev', 'libonig-dev', 'e2fsprogs', 'wget', 'sysstat', 'alsa-utils', 'v4l-utils', 'certbot', 'iptables-persistent', 'libjpeg-dev', 'libpng-dev', 'libharfbuzz-dev', 'libfribidi-dev', 'libogg0', 'libnuma1', 'xz-utils', 'zip', 'unzip', 'libssh2-1', 'libsodium23', 'cpufrequtils', 'mcrypt', 'cron', 'git', 'curl'), - 'debian11' => array('iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'libcurl4', 'libgeoip-dev', 'libxslt1-dev', 'libonig-dev', 'e2fsprogs', 'wget', 'curl', 'unzip', 'zip', 'xz-utils', 'cron', 'git', 'sysstat', 'alsa-utils', 'v4l-utils', 'certbot', 'iptables-persistent', 'libjpeg-dev', 'libpng-dev', 'libharfbuzz-dev', 'libfribidi-dev', 'libogg0', 'libnuma1', 'libssh2-1', 'libssh2-1-dev', 'libsodium23', 'cpufrequtils', 'mcrypt'), - 'debian13' => array('iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'libcurl4', 'wget', 'curl', 'unzip', 'zip', 'xz-utils', 'cron', 'git', 'sysstat', 'perl', 'gawk', 'socat', 'libxml2-dev', 'libxslt1-dev', 'libonig5', 'libonig-dev', 'zlib1g-dev', 'libssl-dev', 'pkg-config', 'autoconf', 'automake', 'alsa-utils', 'v4l-utils', 'e2fsprogs', 'certbot', 'iptables-persistent', 'libssh2-1', 'libssh2-1-dev', 'libjpeg-dev', 'libpng-dev', 'libharfbuzz-dev', 'libfribidi-dev', 'libgeoip1', 'geoip-bin', 'libsodium23', 'cpufrequtils', 'mcrypt', 'libogg0', 'libnuma1'), - 'ubuntu20' => array('iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'wget', 'curl', 'unzip', 'zip', 'xz-utils', 'cron', 'git', 'sysstat', 'ca-certificates', 'libcurl3-gnutls', 'libcurl4-gnutls-dev', 'libxml2-dev', 'libxslt1-dev', 'libonig5', 'libonig-dev', 'libjpeg-dev', 'libpng-dev', 'zlib1g-dev', 'alsa-utils', 'v4l-utils', 'e2fsprogs', 'iptables-persistent', 'certbot', 'python3-certbot', 'libssh2-1', 'libssh2-1-dev', 'libsodium23', 'cpufrequtils', 'mcrypt', 'libogg0', 'libnuma1'), - 'ubuntu22' => array('iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'libcurl4', 'libcurl3-gnutls', 'libgeoip-dev', 'libxslt1-dev', 'libonig-dev', 'e2fsprogs', 'wget', 'curl', 'unzip', 'zip', 'xz-utils', 'cron', 'git', 'sysstat', 'ca-certificates', 'libxml2-dev', 'libonig5', 'zlib1g-dev', 'alsa-utils', 'v4l-utils', 'certbot', 'python3-certbot', 'iptables-persistent', 'libjpeg-dev', 'libpng-dev', 'libharfbuzz-dev', 'libfribidi-dev', 'libogg0', 'libnuma1', 'libssh2-1', 'libssh2-1-dev', 'libsodium23', 'cpufrequtils', 'mcrypt'), - 'ubuntu24' => array('iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'libcurl4t64', 'wget', 'curl', 'unzip', 'zip', 'xz-utils', 'cron', 'git', 'sysstat', 'perl', 'gawk', 'socat', 'libxml2-dev', 'libxslt1-dev', 'libonig5', 'libonig-dev', 'zlib1g-dev', 'libssl-dev', 'pkg-config', 'autoconf', 'automake', 'alsa-utils', 'v4l-utils', 'e2fsprogs', 'certbot', 'python3-certbot', 'ufw', 'libssh2-1t64', 'libssh2-1-dev', 'libjpeg-dev', 'libpng-dev', 'libharfbuzz-dev', 'libfribidi-dev', 'libgeoip1t64', 'geoip-bin', 'libsodium23', 'cpufrequtils', 'mcrypt', 'libogg0', 'libnuma1'), - 'redhat' => array('epel-release', 'wget', 'sysstat', 'alsa-utils', 'v4l-utils', 'libcurl-devel', 'geoip-devel', 'libxslt-devel', 'oniguruma-devel', 'e2fsprogs', 'libjpeg-turbo-devel', 'libpng-devel', 'harfbuzz-devel', 'fribidi-devel', 'libogg', 'xz', 'zip', 'unzip', 'libssh2-devel', 'cronie', 'certbot', 'iptables-services', 'GeoIP-update', 'git', 'curl', 'libsodium', 'numactl', 'kernel-tools'), - ); + $rLists = [ + 'debian' => ['iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'libcurl4', 'libgeoip-dev', 'libxslt1-dev', 'libonig-dev', 'e2fsprogs', 'wget', 'sysstat', 'alsa-utils', 'v4l-utils', 'certbot', 'iptables-persistent', 'libjpeg-dev', 'libpng-dev', 'libharfbuzz-dev', 'libfribidi-dev', 'libogg0', 'libnuma1', 'xz-utils', 'zip', 'unzip', 'libssh2-1', 'libsodium23', 'cpufrequtils', 'mcrypt', 'cron', 'git', 'curl'], + 'debian11' => ['iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'libcurl4', 'libgeoip-dev', 'libxslt1-dev', 'libonig-dev', 'e2fsprogs', 'wget', 'curl', 'unzip', 'zip', 'xz-utils', 'cron', 'git', 'sysstat', 'alsa-utils', 'v4l-utils', 'certbot', 'iptables-persistent', 'libjpeg-dev', 'libpng-dev', 'libharfbuzz-dev', 'libfribidi-dev', 'libogg0', 'libnuma1', 'libssh2-1', 'libssh2-1-dev', 'libsodium23', 'cpufrequtils', 'mcrypt'], + 'debian13' => ['iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'libcurl4', 'wget', 'curl', 'unzip', 'zip', 'xz-utils', 'cron', 'git', 'sysstat', 'perl', 'gawk', 'socat', 'libxml2-dev', 'libxslt1-dev', 'libonig5', 'libonig-dev', 'zlib1g-dev', 'libssl-dev', 'pkg-config', 'autoconf', 'automake', 'alsa-utils', 'v4l-utils', 'e2fsprogs', 'certbot', 'iptables-persistent', 'libssh2-1', 'libssh2-1-dev', 'libjpeg-dev', 'libpng-dev', 'libharfbuzz-dev', 'libfribidi-dev', 'libgeoip1', 'geoip-bin', 'libsodium23', 'cpufrequtils', 'mcrypt', 'libogg0', 'libnuma1'], + 'ubuntu20' => ['iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'wget', 'curl', 'unzip', 'zip', 'xz-utils', 'cron', 'git', 'sysstat', 'ca-certificates', 'libcurl3-gnutls', 'libcurl4-gnutls-dev', 'libxml2-dev', 'libxslt1-dev', 'libonig5', 'libonig-dev', 'libjpeg-dev', 'libpng-dev', 'zlib1g-dev', 'alsa-utils', 'v4l-utils', 'e2fsprogs', 'iptables-persistent', 'certbot', 'python3-certbot', 'libssh2-1', 'libssh2-1-dev', 'libsodium23', 'cpufrequtils', 'mcrypt', 'libogg0', 'libnuma1'], + 'ubuntu22' => ['iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'libcurl4', 'libcurl3-gnutls', 'libgeoip-dev', 'libxslt1-dev', 'libonig-dev', 'e2fsprogs', 'wget', 'curl', 'unzip', 'zip', 'xz-utils', 'cron', 'git', 'sysstat', 'ca-certificates', 'libxml2-dev', 'libonig5', 'zlib1g-dev', 'alsa-utils', 'v4l-utils', 'certbot', 'python3-certbot', 'iptables-persistent', 'libjpeg-dev', 'libpng-dev', 'libharfbuzz-dev', 'libfribidi-dev', 'libogg0', 'libnuma1', 'libssh2-1', 'libssh2-1-dev', 'libsodium23', 'cpufrequtils', 'mcrypt'], + 'ubuntu24' => ['iproute2', 'net-tools', 'dirmngr', 'gpg-agent', 'software-properties-common', 'libcurl4t64', 'wget', 'curl', 'unzip', 'zip', 'xz-utils', 'cron', 'git', 'sysstat', 'perl', 'gawk', 'socat', 'libxml2-dev', 'libxslt1-dev', 'libonig5', 'libonig-dev', 'zlib1g-dev', 'libssl-dev', 'pkg-config', 'autoconf', 'automake', 'alsa-utils', 'v4l-utils', 'e2fsprogs', 'certbot', 'python3-certbot', 'ufw', 'libssh2-1t64', 'libssh2-1-dev', 'libjpeg-dev', 'libpng-dev', 'libharfbuzz-dev', 'libfribidi-dev', 'libgeoip1t64', 'geoip-bin', 'libsodium23', 'cpufrequtils', 'mcrypt', 'libogg0', 'libnuma1'], + 'redhat' => ['epel-release', 'wget', 'sysstat', 'alsa-utils', 'v4l-utils', 'libcurl-devel', 'geoip-devel', 'libxslt-devel', 'oniguruma-devel', 'e2fsprogs', 'libjpeg-turbo-devel', 'libpng-devel', 'harfbuzz-devel', 'fribidi-devel', 'libogg', 'xz', 'zip', 'unzip', 'libssh2-devel', 'cronie', 'certbot', 'iptables-services', 'GeoIP-update', 'git', 'curl', 'libsodium', 'numactl', 'kernel-tools'], + ]; $rKey = self::resolvePackageKey(strtolower(trim($rDistID)), explode('.', $rVersion)[0]); return $rLists[$rKey] ?? $rLists['debian']; @@ -27,26 +26,26 @@ class LbInstallFlow { // Mirrors the MAIN installer's _PACKAGE_KEY_MAP: (dist_id, major_version) -> package list key. private static function resolvePackageKey(string $rDistID, string $rMajor): string { - if (in_array($rDistID, array('rocky', 'almalinux', 'rhel', 'centos', 'redhat', 'fedora'), true)) { + if (in_array($rDistID, ['rocky', 'almalinux', 'rhel', 'centos', 'redhat', 'fedora'], true)) { return 'redhat'; } - $rMap = array( - 'ubuntu' => array('18' => 'ubuntu20', '20' => 'ubuntu20', '22' => 'ubuntu22', '24' => 'ubuntu24'), - 'debian' => array('11' => 'debian11', '12' => 'debian', '13' => 'debian13'), - ); + $rMap = [ + 'ubuntu' => ['18' => 'ubuntu20', '20' => 'ubuntu20', '22' => 'ubuntu22', '24' => 'ubuntu24'], + 'debian' => ['11' => 'debian11', '12' => 'debian', '13' => 'debian13'], + ]; return $rMap[$rDistID][$rMajor] ?? 'debian'; } public static function resolveUpdateData(GitHubReleases $gitRelease): array { $rUpdateData = $gitRelease->getUpdateFile("lb", XC_VM_VERSION); - return array( + return [ 'url' => $rUpdateData['url'], 'md5' => $rUpdateData['md5'], - ); + ]; } public static function writeInstallMetadata(string $rInstallDir, int $rServerID, string $rUsername, string $rPassword, int $rPort): void { - file_put_contents($rInstallDir . $rServerID . '.json', json_encode(array('root_username' => $rUsername, 'root_password' => $rPassword, 'ssh_port' => $rPort))); + file_put_contents($rInstallDir . $rServerID . '.json', json_encode(['root_username' => $rUsername, 'root_password' => $rPassword, 'ssh_port' => $rPort])); } public static function installArchive($rConn, callable $rRunSSH, string $rInstallFiles, string $rHash, int $rServerID, $db): bool { @@ -152,13 +151,13 @@ class LbInstallFlow { // Pack config.enc targeted at the node's install_id. Credentials are // read from MAIN's config.enc inside the extension, never exposed here. - $rBlob = \XC_VM::config_pack($rInstallId, array( + $rBlob = \XC_VM::config_pack($rInstallId, [ 'hostname' => $rServers[SERVER_ID]['server_ip'], 'database' => 'xc_vm', 'port' => intval(ConfigReader::get('port')), 'server_id' => $rServerID, 'is_lb' => 1, - )); + ]); if (empty($rBlob)) { $db->query('UPDATE `servers` SET `status` = 4 WHERE `id` = ?;', $rServerID); echo "Failed to pack node configuration! Exiting\n"; @@ -264,14 +263,14 @@ class LbInstallFlow { */ private static function latestReleaseTagViaRedirect(string $rOwner, string $rRepo): string { $rCurl = curl_init('https://github.com/' . $rOwner . '/' . $rRepo . '/releases/latest'); - curl_setopt_array($rCurl, array( + curl_setopt_array($rCurl, [ CURLOPT_NOBODY => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 20, CURLOPT_USERAGENT => 'XC_VM', - )); + ]); $rHeaders = (string) curl_exec($rCurl); $rLocation = (string) curl_getinfo($rCurl, CURLINFO_REDIRECT_URL); curl_close($rCurl); @@ -391,7 +390,7 @@ class LbInstallFlow { call_user_func($rRunSSH, $rConn, 'sudo chmod 0750 ' . MAIN_HOME . 'bin/nginx_rtmp/sbin/nginx_rtmp'); $rVersionFile = BIN_PATH . 'bin_version.json'; - $rVersionData = array( + $rVersionData = [ 'owner' => GIT_OWNER, 'repository' => GIT_REPO_BIN, 'release' => $rTag, @@ -399,7 +398,7 @@ class LbInstallFlow { 'distribution' => $rDistID, 'distribution_version' => $rVersion, 'updated_at_utc' => gmdate('Y-m-d\TH:i:s\Z'), - ); + ]; $rVersionJson = json_encode($rVersionData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($rVersionJson !== false) { $rEncodedVersion = base64_encode($rVersionJson); diff --git a/src/Cli/Commands/LlodCommand.php b/src/Cli/Commands/LlodCommand.php index c1f8e4e9..5d6bd689 100644 --- a/src/Cli/Commands/LlodCommand.php +++ b/src/Cli/Commands/LlodCommand.php @@ -20,7 +20,6 @@ use XcVm\Streaming\Fanout\IngestFeeder; */ class LlodCommand implements CommandInterface { - public function getName(): string { return 'llod'; } @@ -56,16 +55,36 @@ class LlodCommand implements CommandInterface { echo "Stream arguments count: " . count($rStreamArguments) . "\n"; echo "====================\n\n"; - if (!defined('MAIN_HOME')) define('MAIN_HOME', '/home/xc_vm/'); - if (!defined('STREAMS_PATH')) define('STREAMS_PATH', MAIN_HOME . 'content/streams/'); - if (!defined('CACHE_TMP_PATH')) define('CACHE_TMP_PATH', MAIN_HOME . 'tmp/cache/'); - if (!defined('CONS_TMP_PATH')) define('CONS_TMP_PATH', MAIN_HOME . 'tmp/opened_cons/'); - if (!defined('FFMPEG')) define('FFMPEG', FfmpegPaths::cpu() ?: FFMPEG_BIN_40); - if (!defined('FFPROBE')) define('FFPROBE', FfmpegPaths::probe() ?: FFPROBE_BIN_40); - if (!defined('PACKET_SIZE')) define('PACKET_SIZE', 188); - if (!defined('BUFFER_SIZE')) define('BUFFER_SIZE', 12032); - if (!defined('TIMEOUT')) define('TIMEOUT', 20); - if (!defined('SEGMENT_DURATION')) define('SEGMENT_DURATION', 4); + if (!defined('MAIN_HOME')) { + define('MAIN_HOME', '/home/xc_vm/'); + } + if (!defined('STREAMS_PATH')) { + define('STREAMS_PATH', MAIN_HOME . 'content/streams/'); + } + if (!defined('CACHE_TMP_PATH')) { + define('CACHE_TMP_PATH', MAIN_HOME . 'tmp/cache/'); + } + if (!defined('CONS_TMP_PATH')) { + define('CONS_TMP_PATH', MAIN_HOME . 'tmp/opened_cons/'); + } + if (!defined('FFMPEG')) { + define('FFMPEG', FfmpegPaths::cpu() ?: FFMPEG_BIN_40); + } + if (!defined('FFPROBE')) { + define('FFPROBE', FfmpegPaths::probe() ?: FFPROBE_BIN_40); + } + if (!defined('PACKET_SIZE')) { + define('PACKET_SIZE', 188); + } + if (!defined('BUFFER_SIZE')) { + define('BUFFER_SIZE', 12032); + } + if (!defined('TIMEOUT')) { + define('TIMEOUT', 20); + } + if (!defined('SEGMENT_DURATION')) { + define('SEGMENT_DURATION', 4); + } if (!file_exists(CACHE_TMP_PATH . 'settings')) { echo "Settings not cached!\n"; @@ -78,7 +97,7 @@ class LlodCommand implements CommandInterface { $rFP = null; $rSegmentFile = null; - $rSegmentStatus = array(); + $rSegmentStatus = []; register_shutdown_function(function () use (&$rFP, &$rSegmentFile) { if (is_resource($rSegmentFile)) { @@ -177,7 +196,7 @@ class LlodCommand implements CommandInterface { $segment = 0; $segmentOpen = false; $segmentStart = microtime(true); - $rSegmentDurations = array(); + $rSegmentDurations = []; $lastData = time(); $firstDataAt = microtime(true); @@ -316,7 +335,7 @@ class LlodCommand implements CommandInterface { * @param string|null $pmtPacket Raw 188-byte PMT packet, or null if not seen yet. * @return resource|false The open file handle, or false on failure. */ - private function openSegment($rStreamID, $segment, $patPacket, $pmtPacket) { + private function openSegment(int|string $rStreamID, int $segment, ?string $patPacket, ?string $pmtPacket) { $file = fopen(STREAMS_PATH . $rStreamID . "_{$segment}.ts", 'wb'); if (!$file) { return false; @@ -337,7 +356,7 @@ class LlodCommand implements CommandInterface { * @param string $pkt A 188-byte TS packet. * @return array{pid:int,pusi:bool,random_access:bool,payload_offset:int} */ - private function parseTsHeader($pkt) { + private function parseTsHeader(string $pkt) { $b1 = ord($pkt[1]); $b2 = ord($pkt[2]); $b3 = ord($pkt[3]); @@ -377,7 +396,7 @@ class LlodCommand implements CommandInterface { * @param int $payloadOffset Offset of the payload within the packet. * @return int|null program_map_PID, or null if not resolvable in this packet. */ - private function parsePat($pkt, $payloadOffset) { + private function parsePat(string $pkt, int $payloadOffset) { if ($payloadOffset >= PACKET_SIZE) { return null; } @@ -408,7 +427,7 @@ class LlodCommand implements CommandInterface { * @param int $payloadOffset Offset of the payload within the packet. * @return int|null Video/PCR PID, or null if not resolvable in this packet. */ - private function parsePmt($pkt, $payloadOffset) { + private function parsePmt(string $pkt, int $payloadOffset) { if ($payloadOffset >= PACKET_SIZE) { return null; } @@ -450,7 +469,7 @@ class LlodCommand implements CommandInterface { * @param mixed $rRequestPrebuffer The request_prebuffer setting. * @return resource */ - private function sourceContext($rURL, $rStreamArguments, $rRequestPrebuffer) { + private function sourceContext(string $rURL, array $rStreamArguments, mixed $rRequestPrebuffer) { $rArg = static function (string $rKey) use ($rStreamArguments): string { // `??` already maps a missing or null value to '': an empty value // (after trimming) falls back to the argument's default. @@ -461,7 +480,7 @@ class LlodCommand implements CommandInterface { return $rValue; }; - $rHeaders = array(); + $rHeaders = []; foreach (preg_split('/\r\n|\r|\n/', $rArg('headers')) as $rLine) { if (trim($rLine) !== '' && strpos($rLine, ':') !== false) { $rHeaders[] = trim($rLine); @@ -474,10 +493,10 @@ class LlodCommand implements CommandInterface { $rHeaders[] = 'X-XC_VM-Prebuffer: 1'; } - $rHTTP = array( + $rHTTP = [ 'timeout' => TIMEOUT, 'user_agent' => ($rArg('user_agent') !== '' ? $rArg('user_agent') : 'Mozilla/5.0'), - ); + ]; if (count($rHeaders) > 0) { $rHTTP['header'] = implode("\r\n", $rHeaders); } @@ -487,10 +506,10 @@ class LlodCommand implements CommandInterface { $rHTTP['request_fulluri'] = true; } - return stream_context_create(array( + return stream_context_create([ 'http' => $rHTTP, - 'ssl' => array('verify_peer' => false, 'verify_peer_name' => false), - )); + 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false], + ]); } /** @@ -534,7 +553,7 @@ class LlodCommand implements CommandInterface { $rMetadata = stream_get_meta_data($rFP); echo "Stream metadata obtained\n"; - $rHeaders = array(); + $rHeaders = []; if (!empty($rMetadata['wrapper_data']) && is_array($rMetadata['wrapper_data'])) { foreach ($rMetadata['wrapper_data'] as $rLine) { @@ -605,12 +624,12 @@ class LlodCommand implements CommandInterface { return false; } - private function deleteOldSegments($rStreamID, $rKeep, $rThreshold, &$rSegmentStatus, &$rSegmentDurations = array()): array { + private function deleteOldSegments($rStreamID, $rKeep, $rThreshold, &$rSegmentStatus, &$rSegmentDurations = []): array { echo "Stream ID: $rStreamID\n"; echo "Keep segments: $rKeep\n"; echo "Delete threshold: $rThreshold\n"; - $rReturn = array(); + $rReturn = []; if (empty($rSegmentStatus)) { return $rReturn; @@ -650,7 +669,7 @@ class LlodCommand implements CommandInterface { return $rReturn; } - private function updateSegments($rStreamID, $segments, $rSegmentDurations = array(), $rSegTime = SEGMENT_DURATION): void { + private function updateSegments($rStreamID, $segments, $rSegmentDurations = [], $rSegTime = SEGMENT_DURATION): void { if (empty($segments)) { return; } @@ -705,7 +724,7 @@ class LlodCommand implements CommandInterface { */ private function checkRunning($rStreamID): void { echo "Checking for existing process for stream $rStreamID\n"; - $rTerms = array('LLOD[' . intval($rStreamID) . ']', 'console.php llod ' . intval($rStreamID) . ' '); + $rTerms = ['LLOD[' . intval($rStreamID) . ']', 'console.php llod ' . intval($rStreamID) . ' ']; foreach (ProcessManager::findProcessPIDs($rTerms) as $rPID) { echo "Killing existing LLOD process PID: $rPID\n"; @posix_kill($rPID, 9); diff --git a/src/Cli/Commands/LoopbackCommand.php b/src/Cli/Commands/LoopbackCommand.php index 0631c351..893e3474 100644 --- a/src/Cli/Commands/LoopbackCommand.php +++ b/src/Cli/Commands/LoopbackCommand.php @@ -19,7 +19,6 @@ use XcVm\Streaming\Fanout\IngestFeeder; */ class LoopbackCommand implements CommandInterface { - public function getName(): string { return 'loopback'; } @@ -44,29 +43,59 @@ class LoopbackCommand implements CommandInterface { $rStreamID = intval($rArgs[0]); $rServerID = intval($rArgs[1]); - if (!defined('MAIN_HOME')) define('MAIN_HOME', '/home/xc_vm/'); - if (!defined('STREAMS_PATH')) define('STREAMS_PATH', MAIN_HOME . 'content/streams/'); - if (!defined('FFMPEG')) define('FFMPEG', FfmpegPaths::cpu() ?: FFMPEG_BIN_40); - if (!defined('FFPROBE')) define('FFPROBE', FfmpegPaths::probe() ?: FFPROBE_BIN_40); - if (!defined('CACHE_TMP_PATH')) define('CACHE_TMP_PATH', MAIN_HOME . 'tmp/cache/'); - if (!defined('CONFIG_PATH')) define('CONFIG_PATH', MAIN_HOME . 'config/'); + if (!defined('MAIN_HOME')) { + define('MAIN_HOME', '/home/xc_vm/'); + } + if (!defined('STREAMS_PATH')) { + define('STREAMS_PATH', MAIN_HOME . 'content/streams/'); + } + if (!defined('FFMPEG')) { + define('FFMPEG', FfmpegPaths::cpu() ?: FFMPEG_BIN_40); + } + if (!defined('FFPROBE')) { + define('FFPROBE', FfmpegPaths::probe() ?: FFPROBE_BIN_40); + } + if (!defined('CACHE_TMP_PATH')) { + define('CACHE_TMP_PATH', MAIN_HOME . 'tmp/cache/'); + } + if (!defined('CONFIG_PATH')) { + define('CONFIG_PATH', MAIN_HOME . 'config/'); + } // PAT_HEADER restored to the real PAT header bytes (0xB0 0x0D) derived from the stream. // The value was corrupted (byte 0xB0 → space/U+FFFD) by a non-binary-safe editor. - if (!defined('PAT_HEADER')) define('PAT_HEADER', "\xB0\x0D"); - if (!defined('KEYFRAME_HEADER')) define('KEYFRAME_HEADER', "\x07P"); - if (!defined('PACKET_SIZE')) define('PACKET_SIZE', 188); - if (!defined('BUFFER_SIZE')) define('BUFFER_SIZE', 12032); - if (!defined('PAT_PERIOD')) define('PAT_PERIOD', 2); + if (!defined('PAT_HEADER')) { + define('PAT_HEADER', "\xB0\x0D"); + } + if (!defined('KEYFRAME_HEADER')) { + define('KEYFRAME_HEADER', "\x07P"); + } + if (!defined('PACKET_SIZE')) { + define('PACKET_SIZE', 188); + } + if (!defined('BUFFER_SIZE')) { + define('BUFFER_SIZE', 12032); + } + if (!defined('PAT_PERIOD')) { + define('PAT_PERIOD', 2); + } // Minimum VIDEO duration per segment (90 kHz ticks). ~1.5s guarantees one cut per 2s GOP // and prevents sub-GOP cuts that were producing 8 KB .ts files. Measured in PTS (video time), // so it stays correct even when the source bursts at 400+ Mbps. - if (!defined('MIN_SEG_PTS')) define('MIN_SEG_PTS', 135000); + if (!defined('MIN_SEG_PTS')) { + define('MIN_SEG_PTS', 135000); + } // Segment size safety cap. If the source stops delivering keyframes with an advancing PCR // (e.g. re-serving the same chunk to a consumer), we still rotate on the next keyframe to // avoid writing a giant .ts that fills the disk. ~16 MB ≈ ~20s, well above a normal segment (~800 KB). - if (!defined('MAX_SEG_BYTES')) define('MAX_SEG_BYTES', 16777216); - if (!defined('TIMEOUT')) define('TIMEOUT', 20); - if (!defined('TIMEOUT_READ')) define('TIMEOUT_READ', 1); + if (!defined('MAX_SEG_BYTES')) { + define('MAX_SEG_BYTES', 16777216); + } + if (!defined('TIMEOUT')) { + define('TIMEOUT', 20); + } + if (!defined('TIMEOUT_READ')) { + define('TIMEOUT_READ', 1); + } if (!file_exists(CACHE_TMP_PATH . 'settings')) { echo "Settings not cached!\n"; @@ -77,7 +106,9 @@ class LoopbackCommand implements CommandInterface { return 0; } - if (!defined('SERVER_ID')) define('SERVER_ID', intval(ConfigReader::get('server_id'))); + if (!defined('SERVER_ID')) { + define('SERVER_ID', intval(ConfigReader::get('server_id'))); + } $this->checkRunning($rStreamID); // Single-instance lock per stream. The monitor/watchdog (StreamProcess::startLoopback) can @@ -92,8 +123,8 @@ class LoopbackCommand implements CommandInterface { $rFP = null; $rSegmentFile = null; - $rSegmentDuration = array(); - $rSegmentStatus = array(); + $rSegmentDuration = []; + $rSegmentStatus = []; $rLastPTS = null; $rCurPTS = null; $rSegStartPTS = null; @@ -143,7 +174,7 @@ class LoopbackCommand implements CommandInterface { $rFeeder->connect(); $rExcessBuffer = $rPrebuffer = $rBuffer = $rPacket = ''; - $rPATHeaders = array(); + $rPATHeaders = []; $rNewSegment = $rPAT = false; $rFirstWrite = true; $rLastPacket = time(); @@ -214,7 +245,7 @@ class LoopbackCommand implements CommandInterface { if ($rSync == 71) { if (substr($rPacket, 6, 2) == PAT_HEADER) { $rPAT = true; - $rPATHeaders = array(); + $rPATHeaders = []; } else { $rAdaptationField = $rHeader >> 4 & 3; if (($rAdaptationField & 2) === 2) { @@ -246,14 +277,14 @@ class LoopbackCommand implements CommandInterface { $rPrebuffer = implode('', $rPATHeaders); $rNewSegment = true; $rPAT = false; - $rPATHeaders = array(); + $rPATHeaders = []; $rLastPTS = $rSegStartPTS; $rCurPTS = $rKfPTS; $rSegStartPTS = $rKfPTS; } else { // Still within the current segment duration: consume the PAT/keyframe pair without rotating. $rPAT = false; - $rPATHeaders = array(); + $rPATHeaders = []; } } } @@ -335,7 +366,7 @@ class LoopbackCommand implements CommandInterface { } private function deleteOldSegments($rStreamID, $rKeep, $rThreshold, &$rSegmentStatus): array { - $rReturn = array(); + $rReturn = []; $rCurrentSegment = max(array_keys($rSegmentStatus)); foreach ($rSegmentStatus as $rSegmentID => $rStatus) { if ($rStatus) { diff --git a/src/Cli/Commands/MigrateCommand.php b/src/Cli/Commands/MigrateCommand.php index b60801cc..5a1db976 100644 --- a/src/Cli/Commands/MigrateCommand.php +++ b/src/Cli/Commands/MigrateCommand.php @@ -15,7 +15,6 @@ use XcVm\Cli\CommandInterface; */ class MigrateCommand implements CommandInterface { - public function getName(): string { return 'migrate'; } diff --git a/src/Cli/Commands/ModuleDeleteCommand.php b/src/Cli/Commands/ModuleDeleteCommand.php index 6f1c4149..cf216d47 100644 --- a/src/Cli/Commands/ModuleDeleteCommand.php +++ b/src/Cli/Commands/ModuleDeleteCommand.php @@ -22,40 +22,39 @@ use XcVm\Core\Module\ModuleManager; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class ModuleDeleteCommand implements CommandInterface { + public function getName(): string { + return 'module:delete'; + } - public function getName(): string { - return 'module:delete'; - } + public function getDescription(): string { + return 'Delete a module on a load balancer (files only)'; + } - public function getDescription(): string { - return 'Delete a module on a load balancer (files only)'; - } + public function execute(array $rArgs): int { + register_shutdown_function(function () { + global $db; + if (is_object($db)) { + $db->close_mysql(); + } + }); - public function execute(array $rArgs): int { - register_shutdown_function(function () { - global $db; - if (is_object($db)) { - $db->close_mysql(); - } - }); + $rArg = trim((string) ($rArgs[0] ?? ''), "'\" "); + $rJson = $rArg !== '' ? base64_decode($rArg, true) : false; + $rData = $rJson !== false ? json_decode($rJson, true) : null; + $rName = is_array($rData) ? (string) ($rData['name'] ?? '') : ''; - $rArg = trim((string) ($rArgs[0] ?? ''), "'\" "); - $rJson = $rArg !== '' ? base64_decode($rArg, true) : false; - $rData = $rJson !== false ? json_decode($rJson, true) : null; - $rName = is_array($rData) ? (string) ($rData['name'] ?? '') : ''; + if ($rName === '') { + echo "module:delete: missing module name.\n"; + return 1; + } - if ($rName === '') { - echo "module:delete: missing module name.\n"; - return 1; - } - - try { - (new ModuleManager())->deleteModuleFilesOnly($rName); - echo "module:delete: '{$rName}' removed.\n"; - return 0; - } catch (\Throwable $e) { - echo "module:delete failed for '{$rName}': " . $e->getMessage() . "\n"; - return 1; - } - } + try { + (new ModuleManager())->deleteModuleFilesOnly($rName); + echo "module:delete: '{$rName}' removed.\n"; + return 0; + } catch (\Throwable $e) { + echo "module:delete failed for '{$rName}': " . $e->getMessage() . "\n"; + return 1; + } + } } diff --git a/src/Cli/Commands/ModuleInstallCommand.php b/src/Cli/Commands/ModuleInstallCommand.php index 25a22b1a..24ce976d 100644 --- a/src/Cli/Commands/ModuleInstallCommand.php +++ b/src/Cli/Commands/ModuleInstallCommand.php @@ -30,146 +30,145 @@ use XcVm\Domain\Server\ServerRepository; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class ModuleInstallCommand implements CommandInterface { + public function getName(): string { + return 'module:install'; + } - public function getName(): string { - return 'module:install'; - } + public function getDescription(): string { + return 'Install a module distributed from MAIN (load balancer, files only)'; + } - public function getDescription(): string { - return 'Install a module distributed from MAIN (load balancer, files only)'; - } + public function execute(array $rArgs): int { + register_shutdown_function(function () { + global $db; + if (is_object($db)) { + $db->close_mysql(); + } + }); - public function execute(array $rArgs): int { - register_shutdown_function(function () { - global $db; - if (is_object($db)) { - $db->close_mysql(); - } - }); + $rPayload = $this->decodePayload($rArgs[0] ?? ''); + if ($rPayload === null) { + echo "Invalid module:install payload.\n"; + return 1; + } - $rPayload = $this->decodePayload($rArgs[0] ?? ''); - if ($rPayload === null) { - echo "Invalid module:install payload.\n"; - return 1; - } + $rName = (string) ($rPayload['name'] ?? ''); + $rVersion = (string) ($rPayload['version'] ?? ''); + $rSource = ($rPayload['source'] ?? 'platform') === 'local' ? 'local' : 'platform'; - $rName = (string) ($rPayload['name'] ?? ''); - $rVersion = (string) ($rPayload['version'] ?? ''); - $rSource = ($rPayload['source'] ?? 'platform') === 'local' ? 'local' : 'platform'; + if ($rName === '') { + echo "module:install: missing module name.\n"; + return 1; + } - if ($rName === '') { - echo "module:install: missing module name.\n"; - return 1; - } + $rManager = new ModuleManager(); - $rManager = new ModuleManager(); + try { + if ($rSource === 'platform') { + $rApiKey = (string) (SettingsManager::get('platform_api_key') ?? ''); + if ($rApiKey === '') { + echo "module:install: platform_api_key is not set in settings.\n"; + return 1; + } + echo "Installing store module '{$rName}' v{$rVersion} from platform...\n"; + $rManager->deployFromPlatformFilesOnly($rName, $rVersion, $rApiKey); + } else { + echo "Installing custom module '{$rName}' v{$rVersion} from MAIN...\n"; + $rArchive = $this->fetchArchiveFromMain($rManager->archivePathFor($rName, $rVersion)); + try { + $rManager->deployFromArchiveFilesOnly($rArchive); + } finally { + @unlink($rArchive); + } + } - try { - if ($rSource === 'platform') { - $rApiKey = (string) (SettingsManager::get('platform_api_key') ?? ''); - if ($rApiKey === '') { - echo "module:install: platform_api_key is not set in settings.\n"; - return 1; - } - echo "Installing store module '{$rName}' v{$rVersion} from platform...\n"; - $rManager->deployFromPlatformFilesOnly($rName, $rVersion, $rApiKey); - } else { - echo "Installing custom module '{$rName}' v{$rVersion} from MAIN...\n"; - $rArchive = $this->fetchArchiveFromMain($rManager->archivePathFor($rName, $rVersion)); - try { - $rManager->deployFromArchiveFilesOnly($rArchive); - } finally { - @unlink($rArchive); - } - } + $this->fixOwnership($rName); + echo "module:install: '{$rName}' installed.\n"; + return 0; + } catch (\Throwable $e) { + echo "module:install failed for '{$rName}': " . $e->getMessage() . "\n"; + return 1; + } + } - $this->fixOwnership($rName); - echo "module:install: '{$rName}' installed.\n"; - return 0; - } catch (\Throwable $e) { - echo "module:install failed for '{$rName}': " . $e->getMessage() . "\n"; - return 1; - } - } + /** Decode + validate the base64 JSON payload. */ + private function decodePayload(string $rArg): ?array { + $rArg = trim($rArg, "'\" "); + if ($rArg === '') { + return null; + } + $rJson = base64_decode($rArg, true); + if ($rJson === false) { + return null; + } + $rData = json_decode($rJson, true); + return is_array($rData) ? $rData : null; + } - /** Decode + validate the base64 JSON payload. */ - private function decodePayload(string $rArg): ?array { - $rArg = trim($rArg, "'\" "); - if ($rArg === '') { - return null; - } - $rJson = base64_decode($rArg, true); - if ($rJson === false) { - return null; - } - $rData = json_decode($rJson, true); - return is_array($rData) ? $rData : null; - } + /** + * Pull a custom module archive from MAIN over the internal system API + * (action=getFile). Returns the path to a downloaded temp file. + * + * @throws \RuntimeException If MAIN cannot be located or the download fails. + */ + private function fetchArchiveFromMain(string $rArchivePath): string { + $rMain = null; + foreach (ServerRepository::getAll() as $rServer) { + if (!empty($rServer['is_main'])) { + $rMain = $rServer; + break; + } + } - /** - * Pull a custom module archive from MAIN over the internal system API - * (action=getFile). Returns the path to a downloaded temp file. - * - * @throws \RuntimeException If MAIN cannot be located or the download fails. - */ - private function fetchArchiveFromMain(string $rArchivePath): string { - $rMain = null; - foreach (ServerRepository::getAll() as $rServer) { - if (!empty($rServer['is_main'])) { - $rMain = $rServer; - break; - } - } + if (!$rMain || empty($rMain['server_ip'])) { + throw new \RuntimeException('Could not locate the MAIN server to fetch the module archive.'); + } - if (!$rMain || empty($rMain['server_ip'])) { - throw new \RuntimeException('Could not locate the MAIN server to fetch the module archive.'); - } + $rPass = (string) (SettingsManager::get('live_streaming_pass') ?? ''); + $rUrl = 'http://' . $rMain['server_ip'] . ':' . intval($rMain['http_broadcast_port']) + . '/api?password=' . urlencode($rPass) + . '&action=getFile&filename=' . urlencode($rArchivePath); - $rPass = (string) (SettingsManager::get('live_streaming_pass') ?? ''); - $rUrl = 'http://' . $rMain['server_ip'] . ':' . intval($rMain['http_broadcast_port']) - . '/api?password=' . urlencode($rPass) - . '&action=getFile&filename=' . urlencode($rArchivePath); + $rTmp = rtrim(sys_get_temp_dir(), '/') . '/xc_lbmod_' . bin2hex(random_bytes(8)) . '.zip'; + $rFp = @fopen($rTmp, 'wb'); + if (!$rFp) { + throw new \RuntimeException('Unable to create temporary archive file.'); + } - $rTmp = rtrim(sys_get_temp_dir(), '/') . '/xc_lbmod_' . bin2hex(random_bytes(8)) . '.zip'; - $rFp = @fopen($rTmp, 'wb'); - if (!$rFp) { - throw new \RuntimeException('Unable to create temporary archive file.'); - } + $rCh = curl_init(); + curl_setopt($rCh, CURLOPT_URL, $rUrl); + curl_setopt($rCh, CURLOPT_FILE, $rFp); + curl_setopt($rCh, CURLOPT_FOLLOWLOCATION, false); + curl_setopt($rCh, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($rCh, CURLOPT_TIMEOUT, 120); + $rOk = curl_exec($rCh); + $rCode = curl_getinfo($rCh, CURLINFO_RESPONSE_CODE); + curl_close($rCh); + fclose($rFp); - $rCh = curl_init(); - curl_setopt($rCh, CURLOPT_URL, $rUrl); - curl_setopt($rCh, CURLOPT_FILE, $rFp); - curl_setopt($rCh, CURLOPT_FOLLOWLOCATION, false); - curl_setopt($rCh, CURLOPT_CONNECTTIMEOUT, 10); - curl_setopt($rCh, CURLOPT_TIMEOUT, 120); - $rOk = curl_exec($rCh); - $rCode = curl_getinfo($rCh, CURLINFO_RESPONSE_CODE); - curl_close($rCh); - fclose($rFp); + // serveFile() answers with a JSON {"result":false,...} body (HTTP 200) + // when the file is missing/invalid — detect that by checking the zip + // magic bytes instead of trusting the status code alone. + $rMagic = @file_get_contents($rTmp, false, null, 0, 2); + if (!$rOk || $rCode < 200 || $rCode >= 300 || $rMagic !== 'PK') { + @unlink($rTmp); + throw new \RuntimeException("Failed to download module archive from MAIN (HTTP {$rCode})."); + } - // serveFile() answers with a JSON {"result":false,...} body (HTTP 200) - // when the file is missing/invalid — detect that by checking the zip - // magic bytes instead of trusting the status code alone. - $rMagic = @file_get_contents($rTmp, false, null, 0, 2); - if (!$rOk || $rCode < 200 || $rCode >= 300 || $rMagic !== 'PK') { - @unlink($rTmp); - throw new \RuntimeException("Failed to download module archive from MAIN (HTTP {$rCode})."); - } + return $rTmp; + } - return $rTmp; - } - - /** - * Hand the freshly installed module directory back to the panel user, since - * this command runs as root from the signals cron. - */ - private function fixOwnership(string $rName): void { - if (!preg_match('/^[a-z0-9][a-z0-9\-]*$/', $rName)) { - return; - } - $rDir = (defined('MAIN_HOME') ? MAIN_HOME : '') . 'Modules/' . $rName; - if (is_dir($rDir)) { - shell_exec('sudo chown -R xc_vm:xc_vm ' . escapeshellarg($rDir) . ' 2>/dev/null'); - } - } + /** + * Hand the freshly installed module directory back to the panel user, since + * this command runs as root from the signals cron. + */ + private function fixOwnership(string $rName): void { + if (!preg_match('/^[a-z0-9][a-z0-9\-]*$/', $rName)) { + return; + } + $rDir = (defined('MAIN_HOME') ? MAIN_HOME : '') . 'Modules/' . $rName; + if (is_dir($rDir)) { + shell_exec('sudo chown -R xc_vm:xc_vm ' . escapeshellarg($rDir) . ' 2>/dev/null'); + } + } } diff --git a/src/Cli/Commands/MonitorCommand.php b/src/Cli/Commands/MonitorCommand.php index c3055000..2413420a 100644 --- a/src/Cli/Commands/MonitorCommand.php +++ b/src/Cli/Commands/MonitorCommand.php @@ -34,7 +34,6 @@ use XcVm\Streaming\Fanout\FanoutClient; */ class MonitorCommand implements CommandInterface { - /** {@inheritDoc} */ public function getName(): string { return 'monitor'; @@ -103,7 +102,7 @@ class MonitorCommand implements CommandInterface { $rDelayPID = $rStreamInfo['delay_pid']; $rParentID = $rStreamInfo['parent_id']; $rStreamProbe = false; - $rSources = array(); + $rSources = []; $rSegmentTime = intval(SettingsManager::get('seg_time')); $rPrioritySwitch = false; $rMaxFails = 0; @@ -349,7 +348,7 @@ class MonitorCommand implements CommandInterface { if ($rStreamInfo['parent_id']) { $rForceSource = (!is_null(ServerRepository::getAll()[SERVER_ID]['private_url_ip']) && !is_null(ServerRepository::getAll()[$rStreamInfo['parent_id']]['private_url_ip']) ? ServerRepository::getAll()[$rStreamInfo['parent_id']]['private_url_ip'] : ServerRepository::getAll()[$rStreamInfo['parent_id']]['public_url_ip']) . 'admin/live?stream=' . intval($rStreamID) . '&password=' . urlencode(SettingsManager::get('live_streaming_pass')) . '&extension=ts'; } - $rData = StreamProcess::startLLOD($rStreamID, $rStreamInfo, $rStreamInfo['parent_id'] ? array() : $rStreamArguments, $rForceSource); + $rData = StreamProcess::startLLOD($rStreamID, $rStreamInfo, $rStreamInfo['parent_id'] ? [] : $rStreamArguments, $rForceSource); } } elseif ($rStreamInfo['type'] == 3) { if ((0 < $rPID) && !$rStreamInfo['parent_id'] && (0 < $rStreamInfo['stream_started'])) { @@ -512,7 +511,7 @@ class MonitorCommand implements CommandInterface { * @param mixed $rThreshold fps_threshold percentage (1..100), or empty. * @return bool */ - public static function isFpsBelowThreshold($rFps, $rBaseline, $rThreshold): bool { + public static function isFpsBelowThreshold(float $rFps, float $rBaseline, mixed $rThreshold): bool { $rPercent = min(100, max(1, intval($rThreshold) ?: 90)); return 0 < $rBaseline && $rFps < $rBaseline * $rPercent / 100; } @@ -526,7 +525,7 @@ class MonitorCommand implements CommandInterface { * @param mixed $rCurrentSource The source in use. * @return array */ - public static function higherPrioritySources(array $rSources, $rCurrentSource): array { + public static function higherPrioritySources(array $rSources, mixed $rCurrentSource): array { $rKey = array_search($rCurrentSource, $rSources); if (!is_numeric($rKey)) { return array_values($rSources); // current is not in the list (e.g. it was edited): all are candidates @@ -541,7 +540,7 @@ class MonitorCommand implements CommandInterface { * @param mixed $rRate Raw avg_frame_rate / r_frame_rate value. * @return float Frames per second; 0.0 for empty/zero/malformed input. */ - private static function parseFrameRate($rRate): float { + private static function parseFrameRate(mixed $rRate): float { $rRate = (string) $rRate; if (strpos($rRate, '/') !== false) { list($rNum, $rDen) = array_map('floatval', explode('/', $rRate)); @@ -558,7 +557,7 @@ class MonitorCommand implements CommandInterface { * @param int|null $rNow Timestamp to test against (defaults to now). * @return bool */ - private static function isAutoRestartDue($rAutoRestart, $rNow = null): bool { + private static function isAutoRestartDue(mixed $rAutoRestart, ?int $rNow = null): bool { if (empty($rAutoRestart['days']) || empty($rAutoRestart['at'])) { return false; } @@ -578,7 +577,7 @@ class MonitorCommand implements CommandInterface { * @param mixed $rAllowHevc player_allow_hevc setting. * @return array{0:int,1:?string,2:?string,3:mixed} [compatible, audio, video, resolution] */ - private static function resolveStreamCodecMeta($rStreamInfoJson, $rAllowHevc): array { + private static function resolveStreamCodecMeta(mixed $rStreamInfoJson, mixed $rAllowHevc): array { $rCompatible = 0; $rAudioCodec = $rVideoCodec = $rResolution = null; if ($rStreamInfoJson) { @@ -590,10 +589,10 @@ class MonitorCommand implements CommandInterface { $rResolution = isset($rStreamJSON['codecs']['video']['height']) ? $rStreamJSON['codecs']['video']['height'] : null; } if ($rResolution) { - $rResolution = StreamSorter::getNearest(array(240, 360, 480, 576, 720, 1080, 1440, 2160), $rResolution); + $rResolution = StreamSorter::getNearest([240, 360, 480, 576, 720, 1080, 1440, 2160], $rResolution); } } - return array($rCompatible, $rAudioCodec, $rVideoCodec, $rResolution); + return [$rCompatible, $rAudioCodec, $rVideoCodec, $rResolution]; } /** @@ -606,7 +605,7 @@ class MonitorCommand implements CommandInterface { * @param mixed $rSegmentTime Current segment time. * @return array{0:mixed,1:mixed} [clamped probe, updated segment time] */ - private static function persistSegmentDuration($rProbe, $rStreamID, $rSegmentTime): array { + private static function persistSegmentDuration(mixed $rProbe, mixed $rStreamID, mixed $rSegmentTime): array { if (10 < intval($rProbe['of_duration'])) { $rProbe['of_duration'] = 10; } @@ -614,7 +613,7 @@ class MonitorCommand implements CommandInterface { if ($rSegmentTime < intval($rProbe['of_duration'])) { $rSegmentTime = intval($rProbe['of_duration']); } - return array($rProbe, $rSegmentTime); + return [$rProbe, $rSegmentTime]; } /** diff --git a/src/Cli/Commands/OndemandCommand.php b/src/Cli/Commands/OndemandCommand.php index ccd4a51b..715d3033 100644 --- a/src/Cli/Commands/OndemandCommand.php +++ b/src/Cli/Commands/OndemandCommand.php @@ -22,7 +22,6 @@ use XcVm\Streaming\Fanout\FanoutClient; */ class OndemandCommand implements CommandInterface { - public function getName(): string { return 'ondemand'; } @@ -51,7 +50,7 @@ class OndemandCommand implements CommandInterface { // Kill any OTHER running ondemand instance (dedupe). findProcessPIDs() // skips our own PID and matches both the retitled process and the raw // command line (covers the window before a sibling sets its title). - foreach (ProcessManager::findProcessPIDs(array('XC_VM[Ondemand]', 'console.php ondemand')) as $rOtherPID) { + foreach (ProcessManager::findProcessPIDs(['XC_VM[Ondemand]', 'console.php ondemand']) as $rOtherPID) { @posix_kill($rOtherPID, 9); } @@ -111,15 +110,17 @@ class OndemandCommand implements CommandInterface { } foreach ($rRows as $rRow) { - if ($rRow['online_clients'] > 0 || $rRow['attached'] > 0) + if ($rRow['online_clients'] > 0 || $rRow['attached'] > 0) { continue; + } $rStreamID = $rRow['stream_id']; $pidFile = STREAMS_PATH . $rStreamID . '_.pid'; $monitorFile = STREAMS_PATH . $rStreamID . '_.monitor'; - if (!file_exists($pidFile)) + if (!file_exists($pidFile)) { continue; + } $rPID = (int) @file_get_contents($pidFile); $rMonitorPID = file_exists($monitorFile) ? (int) @file_get_contents($monitorFile) : 0; @@ -129,8 +130,9 @@ class OndemandCommand implements CommandInterface { if (file_exists($queueFile)) { $queue = @igbinary_unserialize(@file_get_contents($queueFile)) ?: []; foreach ($queue as $pid) { - if (ProcessManager::isRunning($pid, 'php-fpm')) + if (ProcessManager::isRunning($pid, 'php-fpm')) { $rQueue++; + } } } @@ -150,10 +152,12 @@ class OndemandCommand implements CommandInterface { FanoutClient::release($rStreamID); FanoutClient::unregister($rStreamID); - if ($rMonitorPID > 0) + if ($rMonitorPID > 0) { @posix_kill($rMonitorPID, 9); - if ($rPID > 0) + } + if ($rPID > 0) { @posix_kill($rPID, 9); + } @shell_exec('rm -f ' . STREAMS_PATH . $rStreamID . '_*'); @unlink($queueFile); @@ -169,8 +173,9 @@ class OndemandCommand implements CommandInterface { usleep(800000); } - if (is_object($db)) + if (is_object($db)) { $db->close_mysql(); + } shell_exec('(sleep 2; ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php ondemand) > /dev/null 2>&1 &'); return 0; diff --git a/src/Cli/Commands/ProxyInstallFlow.php b/src/Cli/Commands/ProxyInstallFlow.php index 7c68da37..784f9573 100644 --- a/src/Cli/Commands/ProxyInstallFlow.php +++ b/src/Cli/Commands/ProxyInstallFlow.php @@ -3,9 +3,8 @@ namespace XcVm\Cli\Commands; class ProxyInstallFlow { - public static function getPackages(): array { - return array('iproute2', 'net-tools', 'libcurl4', 'libcurl3-gnutls', 'libxslt1-dev', 'libonig-dev', 'e2fsprogs', 'wget', 'sysstat', 'mcrypt', 'python3', 'certbot', 'iptables-persistent', 'libjpeg-dev', 'libpng-dev', 'libssh2-1', 'xz-utils', 'zip', 'unzip', 'cron'); + return ['iproute2', 'net-tools', 'libcurl4', 'libcurl3-gnutls', 'libxslt1-dev', 'libonig-dev', 'e2fsprogs', 'wget', 'sysstat', 'mcrypt', 'python3', 'certbot', 'iptables-persistent', 'libjpeg-dev', 'libpng-dev', 'libssh2-1', 'xz-utils', 'zip', 'unzip', 'cron']; } public static function getInstallFile(): string { @@ -13,7 +12,7 @@ class ProxyInstallFlow { } public static function writeInstallMetadata(string $rInstallDir, int $rServerID, string $rUsername, string $rPassword, int $rPort, int $rHTTPPort, int $rHTTPSPort, array $rParentIDs): void { - file_put_contents($rInstallDir . $rServerID . '.json', json_encode(array('root_username' => $rUsername, 'root_password' => $rPassword, 'ssh_port' => $rPort, 'http_broadcast_port' => $rHTTPPort, 'https_broadcast_port' => $rHTTPSPort, 'parent_id' => $rParentIDs))); + file_put_contents($rInstallDir . $rServerID . '.json', json_encode(['root_username' => $rUsername, 'root_password' => $rPassword, 'ssh_port' => $rPort, 'http_broadcast_port' => $rHTTPPort, 'https_broadcast_port' => $rHTTPSPort, 'parent_id' => $rParentIDs])); } public static function installArchive($rConn, callable $rSendFileSSH, callable $rRunSSH, string $rInstallDir, string $rInstallFile, int $rServerID, $db): bool { diff --git a/src/Cli/Commands/QueueCommand.php b/src/Cli/Commands/QueueCommand.php index 2c7bb442..1c894992 100644 --- a/src/Cli/Commands/QueueCommand.php +++ b/src/Cli/Commands/QueueCommand.php @@ -58,7 +58,7 @@ class QueueCommand implements CommandInterface { // ── Movie queue ────────────────────────────────────── if ($db->query("SELECT `id`, `pid` FROM `queue` WHERE `server_id` = ? AND `pid` IS NOT NULL AND `type` = 'movie' ORDER BY `added` ASC;", SERVER_ID)) { - $rDelete = $rInProgress = array(); + $rDelete = $rInProgress = []; if ($db->num_rows() > 0) { foreach ($db->get_rows() as $rRow) { if ($rRow['pid'] && (ProcessManager::isRunning($rRow['pid'], 'ffmpeg') || ProcessManager::isRunning($rRow['pid'], PHP_BIN))) { @@ -86,7 +86,7 @@ class QueueCommand implements CommandInterface { // ── Channel queue ──────────────────────────────── if ($db->query("SELECT `id`, `pid` FROM `queue` WHERE `server_id` = ? AND `pid` IS NOT NULL AND `type` = 'channel' ORDER BY `added` ASC;", SERVER_ID)) { - $rInProgress = array(); + $rInProgress = []; if ($db->num_rows() > 0) { foreach ($db->get_rows() as $rRow) { if ($rRow['pid'] && ProcessManager::isRunning($rRow['pid'], PHP_BIN)) { diff --git a/src/Cli/Commands/RecordCommand.php b/src/Cli/Commands/RecordCommand.php index f13a8e4a..64b1a316 100644 --- a/src/Cli/Commands/RecordCommand.php +++ b/src/Cli/Commands/RecordCommand.php @@ -20,7 +20,6 @@ use XcVm\Streaming\Codec\FfmpegPaths; */ class RecordCommand implements CommandInterface { - use DatabaseAware; public function getName(): string { @@ -170,7 +169,7 @@ class RecordCommand implements CommandInterface { $rImportArray['target_container'] = 'mp4'; $rImportArray['stream_display_name'] = $recordingData['title']; $rImportArray['year'] = date('Y'); - $rImportArray['movie_properties'] = array('kinopoisk_url' => null, 'tmdb_id' => null, 'name' => $recordingData['title'], 'o_name' => $recordingData['title'], 'cover_big' => $recordingData['stream_icon'], 'movie_image' => $recordingData['stream_icon'], 'release_date' => date('Y-m-d', $recordingData['start']), 'episode_run_time' => intval($rSeconds / 60), 'youtube_trailer' => null, 'director' => '', 'actors' => '', 'cast' => '', 'description' => trim($recordingData['description']), 'plot' => trim($recordingData['description']), 'age' => '', 'mpaa_rating' => '', 'rating_count_kinopoisk' => 0, 'country' => '', 'genre' => '', 'backdrop_path' => array(), 'duration_secs' => $rSeconds, 'duration' => sprintf('%02d:%02d:%02d', $rSeconds / 3600, ($rSeconds / 60) % 60, $rSeconds % 60), 'video' => array(), 'audio' => array(), 'bitrate' => 0, 'rating' => 0); + $rImportArray['movie_properties'] = ['kinopoisk_url' => null, 'tmdb_id' => null, 'name' => $recordingData['title'], 'o_name' => $recordingData['title'], 'cover_big' => $recordingData['stream_icon'], 'movie_image' => $recordingData['stream_icon'], 'release_date' => date('Y-m-d', $recordingData['start']), 'episode_run_time' => intval($rSeconds / 60), 'youtube_trailer' => null, 'director' => '', 'actors' => '', 'cast' => '', 'description' => trim($recordingData['description']), 'plot' => trim($recordingData['description']), 'age' => '', 'mpaa_rating' => '', 'rating_count_kinopoisk' => 0, 'country' => '', 'genre' => '', 'backdrop_path' => [], 'duration_secs' => $rSeconds, 'duration' => sprintf('%02d:%02d:%02d', $rSeconds / 3600, ($rSeconds / 60) % 60, $rSeconds % 60), 'video' => [], 'audio' => [], 'bitrate' => 0, 'rating' => 0]; $rImportArray['rating'] = 0; $rImportArray['read_native'] = 0; $rImportArray['movie_symlink'] = 0; @@ -200,7 +199,7 @@ class RecordCommand implements CommandInterface { foreach (json_decode($recordingData['bouquets'], true) as $rBouquet) { $this->addToBouquet($rBouquet, $rInsertID); } - $db->query('UPDATE `streams` SET `stream_source` = ? WHERE `id` = ?;', json_encode(array(VOD_PATH . $rInsertID . '.mp4')), $rInsertID); + $db->query('UPDATE `streams` SET `stream_source` = ? WHERE `id` = ?;', json_encode([VOD_PATH . $rInsertID . '.mp4']), $rInsertID); $db->query('INSERT INTO `streams_servers`(`stream_id`, `server_id`, `parent_id`, `pid`, `to_analyze`) VALUES(?, ?, NULL, 1, 1);', $rInsertID, SERVER_ID); $db->query('UPDATE `recordings` SET `status` = 2, `created_id` = ? WHERE `id` = ?;', $rInsertID, $recordingID); } @@ -278,7 +277,7 @@ class RecordCommand implements CommandInterface { } private function prepareArray($rArray): array { - $UpdateData = $rColumns = $rPlaceholder = $rData = array(); + $UpdateData = $rColumns = $rPlaceholder = $rData = []; foreach (array_keys($rArray) as $rKey) { $rColumns[] = '`' . $this->preparecolumn($rKey) . '`'; $UpdateData[] = '`' . $this->preparecolumn($rKey) . '` = ?'; @@ -290,12 +289,12 @@ class RecordCommand implements CommandInterface { $rPlaceholder[] = '?'; $rData[] = $rValue; } - return array('placeholder' => implode(',', $rPlaceholder), 'columns' => implode(',', $rColumns), 'data' => $rData, 'update' => implode(',', $UpdateData)); + return ['placeholder' => implode(',', $rPlaceholder), 'columns' => implode(',', $rColumns), 'data' => $rData, 'update' => implode(',', $UpdateData)]; } - private function verifyPostTable($rTable, $rData = array(), $rOnlyExisting = false): array { + private function verifyPostTable($rTable, $rData = [], $rOnlyExisting = false): array { $db = self::db(); - $rReturn = array(); + $rReturn = []; $db->query('SELECT `column_name`, `column_default`, `is_nullable`, `data_type` FROM `information_schema`.`columns` WHERE `table_schema` = (SELECT DATABASE()) AND `table_name` = ? ORDER BY `ordinal_position`;', $rTable); foreach ($db->get_rows() as $rRow) { if ($rRow['column_default'] == 'NULL') { @@ -303,7 +302,7 @@ class RecordCommand implements CommandInterface { } $rForceDefault = false; if (!($rRow['is_nullable'] != 'NO' || $rRow['column_default'])) { - if (in_array($rRow['data_type'], array('int', 'float', 'tinyint', 'double', 'decimal', 'smallint', 'mediumint', 'bigint', 'bit'))) { + if (in_array($rRow['data_type'], ['int', 'float', 'tinyint', 'double', 'decimal', 'smallint', 'mediumint', 'bigint', 'bit'])) { $rRow['column_default'] = 0; } else { $rRow['column_default'] = ''; @@ -332,7 +331,7 @@ class RecordCommand implements CommandInterface { $rPID = intval(file_get_contents(ARCHIVE_PATH . $recordingID . '_.record')); } if (empty($rPID)) { - $rPIDs = array(); + $rPIDs = []; exec("ps -ef | grep 'Record\\[" . intval($recordingID) . "\\]' | grep -v grep | awk '{print \$2}'", $rPIDs); foreach ($rPIDs as $rKillPID) { $rKillPID = intval(trim($rKillPID)); diff --git a/src/Cli/Commands/ScannerCommand.php b/src/Cli/Commands/ScannerCommand.php index bab4a254..f30ef1a6 100644 --- a/src/Cli/Commands/ScannerCommand.php +++ b/src/Cli/Commands/ScannerCommand.php @@ -123,7 +123,7 @@ class ScannerCommand implements CommandInterface { $rProcessed = true; } if (!$rProcessed) { - $rStreamArguments[] = array('value' => 'X-XC_VM-Detect:1', 'argument_key' => 'headers', 'argument_cat' => 'fetch', 'argument_wprotocol' => 'http', 'argument_type' => 'text', 'argument_cmd' => "-headers '%s" . "\r\n" . "'"); + $rStreamArguments[] = ['value' => 'X-XC_VM-Detect:1', 'argument_key' => 'headers', 'argument_cat' => 'fetch', 'argument_wprotocol' => 'http', 'argument_type' => 'text', 'argument_cmd' => "-headers '%s" . "\r\n" . "'"]; } } @@ -137,7 +137,7 @@ class ScannerCommand implements CommandInterface { $rProcessed = true; } if (!$rProcessed) { - $rStreamArguments[] = array('value' => 'X-XC_VM-Prebuffer:1', 'argument_key' => 'headers', 'argument_cat' => 'fetch', 'argument_wprotocol' => 'http', 'argument_type' => 'text', 'argument_cmd' => "-headers '%s" . "\r\n" . "'"); + $rStreamArguments[] = ['value' => 'X-XC_VM-Prebuffer:1', 'argument_key' => 'headers', 'argument_cat' => 'fetch', 'argument_wprotocol' => 'http', 'argument_type' => 'text', 'argument_cmd' => "-headers '%s" . "\r\n" . "'"]; } } @@ -156,7 +156,7 @@ class ScannerCommand implements CommandInterface { } $rTime = round(microtime(true) * 1000); - $rFFProbeOutput = json_decode(shell_exec(str_replace(array('{FETCH_OPTIONS}', '{STREAM_SOURCE}'), array($rFetchOptions, escapeshellarg($rStreamSource)), $rFFProbee)), true); + $rFFProbeOutput = json_decode(shell_exec(str_replace(['{FETCH_OPTIONS}', '{STREAM_SOURCE}'], [$rFetchOptions, escapeshellarg($rStreamSource)], $rFFProbee)), true); $rTimeTaken = round(microtime(true) * 1000) - $rTime; if (file_exists(STREAMS_TMP_PATH . $rRow['id'] . '._errors') && 0 < filesize(STREAMS_TMP_PATH . $rRow['id'] . '._errors')) { @@ -192,7 +192,7 @@ class ScannerCommand implements CommandInterface { $rFPS = intval($rFPS / 1000); } if ($rResolution) { - $rResolution = StreamSorter::getNearest(array(240, 360, 480, 576, 720, 1080, 1440, 2160), $rResolution); + $rResolution = StreamSorter::getNearest([240, 360, 480, 576, 720, 1080, 1440, 2160], $rResolution); } $rStatus = 1; } else { diff --git a/src/Cli/Commands/ServerDiagnoseCommand.php b/src/Cli/Commands/ServerDiagnoseCommand.php index c797975d..138faca4 100644 --- a/src/Cli/Commands/ServerDiagnoseCommand.php +++ b/src/Cli/Commands/ServerDiagnoseCommand.php @@ -93,7 +93,7 @@ class ServerDiagnoseCommand implements CommandInterface { echo "Diagnosing node #{$rServerID} FROM the main — " . ($rServer['server_name'] ?? '(no name)') . " @ {$rIP}:{$rPort}\n"; echo str_repeat('-', 64) . "\n"; - $rProblems = array(); + $rProblems = []; $this->heartbeatSection($rServer, $rNow, $rProblems); @@ -132,7 +132,7 @@ class ServerDiagnoseCommand implements CommandInterface { echo "Self-diagnosis on node #" . SERVER_ID . " — " . ($rMe['server_name'] ?? '(no name)') . " (type " . ($rMe['server_type'] ?? '?') . ")\n"; echo str_repeat('-', 64) . "\n"; - $rProblems = array(); + $rProblems = []; // 1. How the main currently sees me (my own row in the shared DB). $this->heartbeatSection($rMe, $rNow, $rProblems); @@ -259,8 +259,8 @@ class ServerDiagnoseCommand implements CommandInterface { private function signalSection(int $rServerID, int $rNow, array &$rProblems): void { $db = self::db(); $db->query('SELECT COUNT(*) AS `c`, MIN(`time`) AS `oldest` FROM `signals` WHERE `server_id` = ?;', $rServerID); - $rRows = $db->get_rows() ?: array(); - $rSig = $rRows[0] ?? array(); + $rRows = $db->get_rows() ?: []; + $rSig = $rRows[0] ?? []; $rBacklog = intval($rSig['c'] ?? 0); $rOldest = $rBacklog > 0 ? ($rNow - intval($rSig['oldest'])) : 0; $rOk = ($rBacklog === 0) || ($rOldest < self::SIGNAL_STUCK_AFTER); @@ -295,7 +295,7 @@ class ServerDiagnoseCommand implements CommandInterface { } private function icmpPing(string $rIP): bool { - $rOut = array(); + $rOut = []; $rCode = 1; exec('ping -c1 -W2 ' . escapeshellarg($rIP) . ' 2>/dev/null', $rOut, $rCode); return $rCode === 0; @@ -316,22 +316,22 @@ class ServerDiagnoseCommand implements CommandInterface { /** @return array{0:int,1:string} [http_code, curl_error]; code 0 = no response. */ private function httpApi(string $rURL): array { $ch = curl_init($rURL); - curl_setopt_array($ch, array( + curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 6, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, - )); + ]); curl_exec($ch); $rCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $rErr = curl_error($ch); curl_close($ch); - return array($rCode, $rErr); + return [$rCode, $rErr]; } private function svcActive(string $rName): bool { - $rOut = array(); + $rOut = []; $rCode = 1; exec('systemctl is-active ' . escapeshellarg($rName) . ' 2>/dev/null', $rOut, $rCode); return trim(implode('', $rOut)) === 'active'; @@ -339,7 +339,7 @@ class ServerDiagnoseCommand implements CommandInterface { /** @return true|false|string true=blocked, false=not, string=could not check. */ private function iptablesBlocks(string $rIP) { - $rOut = array(); + $rOut = []; $rCode = 1; exec('sudo -n iptables -nL INPUT 2>/dev/null', $rOut, $rCode); if ($rCode !== 0) { @@ -356,7 +356,7 @@ class ServerDiagnoseCommand implements CommandInterface { private function crontabHas(string $rNeedle): bool { // Panel crons live in the xc_vm USER's crontab (LegacyInitializer::generateCron), // not root's. `-u xc_vm` needs root; fall back to the caller's own crontab. - $rOut = array(); + $rOut = []; $rCode = 1; exec('crontab -u xc_vm -l 2>/dev/null', $rOut, $rCode); if ($rCode !== 0) { @@ -370,7 +370,7 @@ class ServerDiagnoseCommand implements CommandInterface { /** Whether a process whose command line matches the ERE $rPattern is running. */ private function processRunning(string $rPattern): bool { - $rOut = array(); + $rOut = []; $rCode = 1; exec('pgrep -f ' . escapeshellarg($rPattern) . ' 2>/dev/null', $rOut, $rCode); return $rCode === 0 && count($rOut) > 0; diff --git a/src/Cli/Commands/ServerInstallCommand.php b/src/Cli/Commands/ServerInstallCommand.php index 6608cc92..885181e2 100644 --- a/src/Cli/Commands/ServerInstallCommand.php +++ b/src/Cli/Commands/ServerInstallCommand.php @@ -25,7 +25,6 @@ use XcVm\Domain\Server\ServerRepository; */ class ServerInstallCommand implements CommandInterface { - public function getName(): string { return 'server:install'; } @@ -54,7 +53,7 @@ class ServerInstallCommand implements CommandInterface { return 0; } - shell_exec("kill -9 `ps -ef | grep 'XC_VM Install\\[" . $rServerID . "\\]' | grep -v grep | awk '{print \$2}'`;" ); + shell_exec("kill -9 `ps -ef | grep 'XC_VM Install\\[" . $rServerID . "\\]' | grep -v grep | awk '{print \$2}'`;"); set_time_limit(0); cli_set_process_title('XC_VM Install[' . $rServerID . ']'); register_shutdown_function(function () use ($db) { @@ -73,7 +72,7 @@ class ServerInstallCommand implements CommandInterface { $rHTTPSPort = (empty($rArgs[6]) ? 443 : intval($rArgs[6])); $rUpdateSysctl = (empty($rArgs[7]) ? 0 : intval($rArgs[7])); $rPrivateIP = (empty($rArgs[8]) ? 0 : intval($rArgs[8])); - $rParentIDs = (empty($rArgs[9]) ? array() : json_decode($rArgs[9], true)); + $rParentIDs = (empty($rArgs[9]) ? [] : json_decode($rArgs[9], true)); $rSysCtl = '# XC_VM' . PHP_EOL . PHP_EOL . 'net.ipv4.tcp_congestion_control = bbr' . PHP_EOL . 'net.core.default_qdisc = fq' . PHP_EOL . 'net.ipv4.tcp_rmem = 8192 87380 134217728' . PHP_EOL . 'net.ipv4.udp_rmem_min = 16384' . PHP_EOL . 'net.core.rmem_default = 262144' . PHP_EOL . 'net.core.rmem_max = 268435456' . PHP_EOL . 'net.ipv4.tcp_wmem = 8192 65536 134217728' . PHP_EOL . 'net.ipv4.udp_wmem_min = 16384' . PHP_EOL . 'net.core.wmem_default = 262144' . PHP_EOL . 'net.core.wmem_max = 268435456' . PHP_EOL . 'net.core.somaxconn = 1000000' . PHP_EOL . 'net.core.netdev_max_backlog = 250000' . PHP_EOL . 'net.core.optmem_max = 65535' . PHP_EOL . 'net.ipv4.tcp_max_tw_buckets = 1440000' . PHP_EOL . 'net.ipv4.tcp_max_orphans = 16384' . PHP_EOL . 'net.ipv4.ip_local_port_range = 2000 65000' . PHP_EOL . 'net.ipv4.tcp_no_metrics_save = 1' . PHP_EOL . 'net.ipv4.tcp_slow_start_after_idle = 0' . PHP_EOL . 'net.ipv4.tcp_fin_timeout = 15' . PHP_EOL . 'net.ipv4.tcp_keepalive_time = 300' . PHP_EOL . 'net.ipv4.tcp_keepalive_probes = 5' . PHP_EOL . 'net.ipv4.tcp_keepalive_intvl = 15' . PHP_EOL . 'fs.file-max=20970800' . PHP_EOL . 'fs.nr_open=20970800' . PHP_EOL . 'fs.aio-max-nr=20970800' . PHP_EOL . 'net.ipv4.tcp_timestamps = 1' . PHP_EOL . 'net.ipv4.tcp_window_scaling = 1' . PHP_EOL . 'net.ipv4.tcp_mtu_probing = 1' . PHP_EOL . 'net.ipv4.route.flush = 1' . PHP_EOL . 'net.ipv6.route.flush = 1'; $rInstallDir = BIN_PATH . 'install/'; @@ -188,7 +187,7 @@ class ServerInstallCommand implements CommandInterface { ProxyInstallFlow::runStartup($rConn, $rRunSSH); } - if (in_array($rType, array(1, 2))) { + if (in_array($rType, [1, 2])) { $db->query('UPDATE `servers` SET `status` = 1, `http_broadcast_port` = ?, `https_broadcast_port` = ?, `total_services` = ? WHERE `id` = ?;', $rHTTPPort, $rHTTPSPort, $rServices, $rServerID); } else { $db->query('UPDATE `servers` SET `status` = 1 WHERE `id` = ?;', $rServerID); @@ -228,7 +227,7 @@ class ServerInstallCommand implements CommandInterface { } private function prepareInstallRoot($rConn, callable $rRunSSH, int $rType): void { - if (!in_array($rType, array(1, 2))) { + if (!in_array($rType, [1, 2])) { return; } @@ -315,6 +314,6 @@ class ServerInstallCommand implements CommandInterface { $rError = ssh2_fetch_stream($rStream, SSH2_STREAM_STDERR); stream_set_blocking($rError, true); stream_set_blocking($rStream, true); - return array('output' => stream_get_contents($rStream), 'error' => stream_get_contents($rError)); + return ['output' => stream_get_contents($rStream), 'error' => stream_get_contents($rError)]; } -} \ No newline at end of file +} diff --git a/src/Cli/Commands/ServiceCommand.php b/src/Cli/Commands/ServiceCommand.php index 16551737..0d1a76a0 100644 --- a/src/Cli/Commands/ServiceCommand.php +++ b/src/Cli/Commands/ServiceCommand.php @@ -21,7 +21,6 @@ use XcVm\Cli\CommandInterface; */ class ServiceCommand implements CommandInterface { - public function getName(): string { return 'service'; } diff --git a/src/Cli/Commands/SignalsCommand.php b/src/Cli/Commands/SignalsCommand.php index 0c8aac55..51bf9148 100644 --- a/src/Cli/Commands/SignalsCommand.php +++ b/src/Cli/Commands/SignalsCommand.php @@ -80,7 +80,7 @@ class SignalsCommand implements CommandInterface { // ── Kill-сигналы из БД ────────────────────────────── if ($db->query('SELECT `signal_id`, `pid`, `rtmp` FROM `signals` WHERE `server_id` = ? AND `pid` IS NOT NULL ORDER BY `signal_id` ASC LIMIT 100', SERVER_ID)) { if ($db->num_rows() > 0) { - $rIDs = array(); + $rIDs = []; foreach ($db->get_rows() as $rRow) { $rIDs[] = $rRow['signal_id']; $rPID = $rRow['pid']; @@ -100,7 +100,7 @@ class SignalsCommand implements CommandInterface { // ── Cache-сигналы из БД ───────────────────────── if ($db->query('SELECT `signal_id`, `custom_data` FROM `signals` WHERE `server_id` = ? AND `cache` = 1 ORDER BY `signal_id` ASC LIMIT 1000;', SERVER_ID)) { if ($db->num_rows() > 0) { - $rUpdatedStreams = $rUpdatedLines = $rIDs = array(); + $rUpdatedStreams = $rUpdatedLines = $rIDs = []; foreach ($db->get_rows() as $rRow) { $rCustomData = json_decode($rRow['custom_data'], true); $rIDs[] = $rRow['signal_id']; @@ -163,13 +163,13 @@ class SignalsCommand implements CommandInterface { // ── Redis kill-сигналы ────────────────────── if (SettingsManager::get('redis_handler')) { - $rSignals = array(); + $rSignals = []; foreach (RedisManager::instance()->sMembers('SIGNALS#' . SERVER_ID) as $rKey) { $rSignals[] = $rKey; } if (count($rSignals) > 0) { $rSignalData = RedisManager::instance()->mGet($rSignals); - $rIDs = array(); + $rIDs = []; foreach ($rSignalData as $rData) { $rRow = igbinary_unserialize($rData); $rIDs[] = $rRow['key']; diff --git a/src/Cli/Commands/StartupCommand.php b/src/Cli/Commands/StartupCommand.php index 5784543b..2c0d2593 100644 --- a/src/Cli/Commands/StartupCommand.php +++ b/src/Cli/Commands/StartupCommand.php @@ -95,7 +95,7 @@ class StartupCommand implements CommandInterface { } private function installRootCrontab(): void { - $rCrons = array(); + $rCrons = []; $rCrons[] = '* * * * * ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:root_signals # XC_VM'; if (file_exists(MAIN_HOME . 'Cli/CronJobs/RootMysqlCronJob.php')) { $rCrons[] = '* * * * * ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:root_mysql # XC_VM'; @@ -112,13 +112,13 @@ class StartupCommand implements CommandInterface { } $rWrite = false; - $rOutput = array(); + $rOutput = []; exec('sudo crontab -l', $rOutput); // Удаляем старые записи XC_VM: путь v1.x.x (crons/root_) и любые // строки с нашим маркером — включая старый '# \XC_VM' от прошлой // миграции — чтобы при апгрейде не появлялись дубликаты. - $rFiltered = array(); + $rFiltered = []; foreach ($rOutput as $rLine) { if (strpos($rLine, MAIN_HOME . 'crons/root_') !== false || strpos($rLine, '# XC_VM') !== false diff --git a/src/Cli/Commands/StatusCommand.php b/src/Cli/Commands/StatusCommand.php index 2a8553f9..ac93c213 100644 --- a/src/Cli/Commands/StatusCommand.php +++ b/src/Cli/Commands/StatusCommand.php @@ -28,7 +28,6 @@ use XcVm\Infrastructure\Database\DatabaseFactory; */ class StatusCommand implements CommandInterface { - use DatabaseAware; public function getName(): string { @@ -149,14 +148,14 @@ class StatusCommand implements CommandInterface { private function getServers(): array { $db = self::db(); $db->query('SELECT * FROM `servers`'); - $rServers = array(); - $rOnlineStatus = array(1); + $rServers = []; + $rOnlineStatus = [1]; foreach ($db->get_rows() as $rRow) { if (empty($rRow['domain_name'])) { $rURL = escapeshellcmd($rRow['server_ip']); } else { - $rURL = str_replace(array('http://', '/', 'https://'), '', escapeshellcmd(explode(',', $rRow['domain_name'])[0])); + $rURL = str_replace(['http://', '/', 'https://'], '', escapeshellcmd(explode(',', $rRow['domain_name'])[0])); } $rProtocol = ($rRow['enable_https'] == 1) ? 'https' : 'http'; @@ -186,7 +185,7 @@ class StatusCommand implements CommandInterface { $rReload = true; } - foreach (array('http', 'https') as $rType) { + foreach (['http', 'https'] as $rType) { $rPortConfig = file_get_contents(MAIN_HOME . 'bin/nginx/conf/ports/' . $rType . '.conf'); if (stripos($rPortConfig, ' reuseport') !== false) { @@ -199,7 +198,7 @@ class StatusCommand implements CommandInterface { } private function installRootCrontab(): void { - $rCrons = array(); + $rCrons = []; $rCrons[] = '* * * * * ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:root_signals # XC_VM'; if (file_exists(MAIN_HOME . 'Cli/CronJobs/RootMysqlCronJob.php')) { @@ -211,12 +210,12 @@ class StatusCommand implements CommandInterface { } $rWrite = false; - $rOutput = array(); + $rOutput = []; exec('sudo crontab -l', $rOutput); // Удаляем старые строки с нашим маркером (включая '# \XC_VM' от // прошлой миграции), чтобы при апгрейде не появлялись дубликаты. - $rFiltered = array(); + $rFiltered = []; foreach ($rOutput as $rLine) { if (strpos($rLine, '# XC_VM') !== false || strpos($rLine, '# \XC_VM') !== false) { $rWrite = true; @@ -285,8 +284,8 @@ class StatusCommand implements CommandInterface { private function broadcastUpdateBinaries(array $rServers): void { $db = self::db(); foreach ($rServers as $rServerID => $rServerArray) { - $db->query('DELETE FROM `signals` WHERE `custom_data` = ?;', json_encode(array('action' => 'update_binaries'))); - $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', $rServerID, time(), json_encode(array('action' => 'update_binaries'))); + $db->query('DELETE FROM `signals` WHERE `custom_data` = ?;', json_encode(['action' => 'update_binaries'])); + $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', $rServerID, time(), json_encode(['action' => 'update_binaries'])); } } diff --git a/src/Cli/Commands/ThumbnailCommand.php b/src/Cli/Commands/ThumbnailCommand.php index 9bb10283..8990172b 100644 --- a/src/Cli/Commands/ThumbnailCommand.php +++ b/src/Cli/Commands/ThumbnailCommand.php @@ -18,7 +18,6 @@ use XcVm\Streaming\Codec\FfmpegPaths; */ class ThumbnailCommand implements CommandInterface { - public function getName(): string { return 'thumbnail'; } diff --git a/src/Cli/Commands/ToolsCommand.php b/src/Cli/Commands/ToolsCommand.php index 3e8da77c..89b21fa9 100644 --- a/src/Cli/Commands/ToolsCommand.php +++ b/src/Cli/Commands/ToolsCommand.php @@ -24,7 +24,6 @@ use XcVm\Infrastructure\Database\DatabaseAware; */ class ToolsCommand implements CommandInterface { - use DatabaseAware; public function getName(): string { @@ -43,8 +42,8 @@ class ToolsCommand implements CommandInterface { $rMethod = (!empty($rArgs[0]) ? $rArgs[0] : null); $rUser = posix_getpwuid(posix_geteuid())['name']; - $rRootMethods = array('rescue', 'recaptcha', 'access', 'ports', 'migration', 'user', 'mysql', 'database', 'flush'); - $rUserMethods = array('images', 'duplicates', 'bouquets'); + $rRootMethods = ['rescue', 'recaptcha', 'access', 'ports', 'migration', 'user', 'mysql', 'database', 'flush']; + $rUserMethods = ['images', 'duplicates', 'bouquets']; // No or unknown subcommand → show the full help to any user (root or xc_vm) if ($rMethod === null || (!in_array($rMethod, $rRootMethods, true) && !in_array($rMethod, $rUserMethods, true))) { @@ -257,21 +256,21 @@ class ToolsCommand implements CommandInterface { private function processPorts(array $rServers): int { echo "Generating port configuration...\n\n"; - $rConfig = array( + $rConfig = [ 'http' => array_unique(array_merge( - array($rServers[SERVER_ID]['http_broadcast_port']), - (explode(',', $rServers[SERVER_ID]['http_ports_add']) ?: array()) + [$rServers[SERVER_ID]['http_broadcast_port']], + (explode(',', $rServers[SERVER_ID]['http_ports_add']) ?: []) )), 'https' => array_unique(array_merge( - array($rServers[SERVER_ID]['https_broadcast_port']), - (explode(',', $rServers[SERVER_ID]['https_ports_add']) ?: array()) + [$rServers[SERVER_ID]['https_broadcast_port']], + (explode(',', $rServers[SERVER_ID]['https_ports_add']) ?: []) )), 'rtmp' => $rServers[SERVER_ID]['rtmp_port'], - ); + ]; foreach ($rConfig as $rKey => $rPorts) { if ($rKey === 'http') { - $rListen = array(); + $rListen = []; foreach ($rPorts as $rPort) { if (is_numeric($rPort) && 80 <= $rPort && $rPort <= 65535) { $rListen[] = 'listen ' . intval($rPort) . ';'; @@ -280,7 +279,7 @@ class ToolsCommand implements CommandInterface { file_put_contents(MAIN_HOME . 'bin/nginx/conf/ports/http.conf', implode(' ', $rListen)); file_put_contents(MAIN_HOME . 'bin/nginx_rtmp/conf/live.conf', 'on_play http://127.0.0.1:' . intval($rPorts[0]) . '/stream/rtmp; on_publish http://127.0.0.1:' . intval($rPorts[0]) . '/stream/rtmp; on_play_done http://127.0.0.1:' . intval($rPorts[0]) . '/stream/rtmp;'); } elseif ($rKey === 'https') { - $rListen = array(); + $rListen = []; foreach ($rPorts as $rPort) { if (is_numeric($rPort) && 80 <= $rPort && $rPort <= 65535) { $rListen[] = 'listen ' . intval($rPort) . ' ssl;'; @@ -309,13 +308,13 @@ class ToolsCommand implements CommandInterface { private function processImages(): void { $db = self::db(); - $rImages = array(); + $rImages = []; $db->query('SELECT COUNT(*) AS `count` FROM `streams`;'); $rCount = $db->get_row()['count']; if ($rCount > 0) { $rSteps = range(0, $rCount, 1000); if (!$rSteps) { - $rSteps = array(0); + $rSteps = [0]; } foreach ($rSteps as $rStep) { try { @@ -346,7 +345,7 @@ class ToolsCommand implements CommandInterface { if ($rCount > 0) { $rSteps = range(0, $rCount, 1000); if (!$rSteps) { - $rSteps = array(0); + $rSteps = [0]; } foreach ($rSteps as $rStep) { try { @@ -389,7 +388,7 @@ class ToolsCommand implements CommandInterface { private function processDuplicates(): void { $db = self::db(); - $rGroups = $rStreamIDs = array(); + $rGroups = $rStreamIDs = []; $db->query('SELECT `a`.`id`, `a`.`stream_source` FROM `streams` `a` INNER JOIN (SELECT `stream_source`, COUNT(*) `totalCount` FROM `streams` WHERE `type` IN (2,5) GROUP BY `stream_source`) `b` ON `a`.`stream_source` = `b`.`stream_source` WHERE `b`.`totalCount` > 1;'); foreach ($db->get_rows() as $rRow) { $rGroups[md5($rRow['stream_source'])][] = $rRow['id']; @@ -409,7 +408,7 @@ class ToolsCommand implements CommandInterface { private function processBouquets(): void { $db = self::db(); - $rStreamIDs = array(array(), array()); + $rStreamIDs = [[], []]; $db->query('SELECT `id` FROM `streams`;'); if ($db->num_rows() > 0) { foreach ($db->get_rows() as $rRow) { @@ -425,23 +424,23 @@ class ToolsCommand implements CommandInterface { $db->query('SELECT * FROM `bouquets` ORDER BY `bouquet_order` ASC;'); if ($db->num_rows() > 0) { foreach ($db->get_rows() as $rBouquet) { - $UpdateData = array(array(), array(), array(), array()); - foreach ((json_decode($rBouquet['bouquet_channels'], true) ?: array()) as $rID) { + $UpdateData = [[], [], [], []]; + foreach ((json_decode($rBouquet['bouquet_channels'], true) ?: []) as $rID) { if (0 < intval($rID) && in_array(intval($rID), $rStreamIDs[0])) { $UpdateData[0][] = intval($rID); } } - foreach ((json_decode($rBouquet['bouquet_movies'], true) ?: array()) as $rID) { + foreach ((json_decode($rBouquet['bouquet_movies'], true) ?: []) as $rID) { if (0 < intval($rID) && in_array(intval($rID), $rStreamIDs[0])) { $UpdateData[1][] = intval($rID); } } - foreach ((json_decode($rBouquet['bouquet_radios'], true) ?: array()) as $rID) { + foreach ((json_decode($rBouquet['bouquet_radios'], true) ?: []) as $rID) { if (0 < intval($rID) && in_array(intval($rID), $rStreamIDs[0])) { $UpdateData[2][] = intval($rID); } } - foreach ((json_decode($rBouquet['bouquet_series'], true) ?: array()) as $rID) { + foreach ((json_decode($rBouquet['bouquet_series'], true) ?: []) as $rID) { if (0 < intval($rID) && in_array(intval($rID), $rStreamIDs[1])) { $UpdateData[3][] = intval($rID); } @@ -468,9 +467,9 @@ class ToolsCommand implements CommandInterface { $db->query('SELECT `server_id` FROM `streams_servers` WHERE `stream_id` IN (' . implode(',', $rIDs) . ');'); $db->query('DELETE FROM `streams_servers` WHERE `stream_id` IN (' . implode(',', $rIDs) . ');'); $db->query('DELETE FROM `streams_servers` WHERE `parent_id` IS NOT NULL AND `parent_id` > 0 AND `parent_id` NOT IN (SELECT `id` FROM `servers` WHERE `server_type` = 0);'); - $db->query('INSERT INTO `signals`(`server_id`, `cache`, `time`, `custom_data`) VALUES(?, 1, ?, ?);', SERVER_ID, time(), json_encode(array('type' => 'update_streams', 'id' => $rIDs))); + $db->query('INSERT INTO `signals`(`server_id`, `cache`, `time`, `custom_data`) VALUES(?, 1, ?, ?);', SERVER_ID, time(), json_encode(['type' => 'update_streams', 'id' => $rIDs])); foreach (array_keys(ServerRepository::getAll()) as $rServerID) { - $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`, `cache`) VALUES(?, ?, ?, 1);', $rServerID, time(), json_encode(array('type' => 'delete_vods', 'id' => $rIDs))); + $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`, `cache`) VALUES(?, ?, ?, 1);', $rServerID, time(), json_encode(['type' => 'delete_vods', 'id' => $rIDs])); } return true; } diff --git a/src/Cli/Commands/UpdateCommand.php b/src/Cli/Commands/UpdateCommand.php index f7e112ad..f8f6154c 100644 --- a/src/Cli/Commands/UpdateCommand.php +++ b/src/Cli/Commands/UpdateCommand.php @@ -22,7 +22,6 @@ use XcVm\Domain\Server\ServerRepository; */ class UpdateCommand implements CommandInterface { - public function getName(): string { return 'update'; } @@ -282,7 +281,7 @@ class UpdateCommand implements CommandInterface { UpdateLogger::info('Broadcasting update signal to LB servers'); foreach (ServerRepository::getAll() as $rServer) { if (($rServer['enabled'] && $rServer['status'] == 1 && time() - $rServer['last_check_ago'] <= 180) || !$rServer['is_main']) { - $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', $rServer['id'], time(), json_encode(array('action' => 'update'))); + $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', $rServer['id'], time(), json_encode(['action' => 'update'])); } } } @@ -291,7 +290,7 @@ class UpdateCommand implements CommandInterface { $db->query('UPDATE `settings` SET `update_data` = NULL;'); UpdateLogger::info('Server status set to 1 (online), version=' . XC_VM_VERSION); - foreach (array('http', 'https') as $rType) { + foreach (['http', 'https'] as $rType) { $rPortConfig = file_get_contents(MAIN_HOME . 'bin/nginx/conf/ports/' . $rType . '.conf'); if (stripos($rPortConfig, ' reuseport') !== false) { file_put_contents(MAIN_HOME . 'bin/nginx/conf/ports/' . $rType . '.conf', str_replace(' reuseport', '', $rPortConfig)); @@ -338,7 +337,9 @@ class UpdateCommand implements CommandInterface { private function downloadFile($url, $targetPath): bool { $rData = @fopen($url, 'rb'); - if (!$rData) return false; + if (!$rData) { + return false; + } $rOutput = fopen($targetPath, 'wb'); stream_copy_to_stream($rData, $rOutput); fclose($rData); diff --git a/src/Cli/Commands/WatchdogCommand.php b/src/Cli/Commands/WatchdogCommand.php index 085cbf5f..81a8c8b9 100644 --- a/src/Cli/Commands/WatchdogCommand.php +++ b/src/Cli/Commands/WatchdogCommand.php @@ -113,13 +113,13 @@ class WatchdogCommand implements CommandInterface { $rInfoA = explode(' ', preg_replace('!cpu +!', '', $rPrevStat[0])); $rInfoB = explode(' ', preg_replace('!cpu +!', '', $rStat[0])); $rPrevStat = $rStat; - $rDiff = array(); + $rDiff = []; $rDiff['user'] = intval($rInfoB[0]) - intval($rInfoA[0]); $rDiff['nice'] = intval($rInfoB[1]) - intval($rInfoA[1]); $rDiff['sys'] = intval($rInfoB[2]) - intval($rInfoA[2]); $rDiff['idle'] = intval($rInfoB[3]) - intval($rInfoA[3]); $rTotal = array_sum($rDiff); - $rCPU = array(); + $rCPU = []; foreach ($rDiff as $x => $y) { $rCPU[$x] = round($y / $rTotal * 100, 2); } @@ -134,8 +134,8 @@ class WatchdogCommand implements CommandInterface { $rStats['fanout'] = FanoutClient::status(); // ── PHP PIDs ───────────────────────────────────────── - $rPHPPIDs = array(); - foreach (glob(MAIN_HOME . 'bin/php/sockets/*.pid') ?: array() as $rPidFile) { + $rPHPPIDs = []; + foreach (glob(MAIN_HOME . 'bin/php/sockets/*.pid') ?: [] as $rPidFile) { $rPid = trim(@file_get_contents($rPidFile) ?: ''); if (is_numeric($rPid) && 0 < intval($rPid)) { $rPHPPIDs[] = intval($rPid); @@ -165,11 +165,11 @@ class WatchdogCommand implements CommandInterface { foreach (array_keys($rServers) as $rServerID) { if ($rServers[$rServerID]['server_online']) { $rMulti->zCard('SERVER#' . $rServerID); - $rMulti->zRangeByScore('SERVER_LINES#' . $rServerID, '-inf', '+inf', array('withscores' => true)); + $rMulti->zRangeByScore('SERVER_LINES#' . $rServerID, '-inf', '+inf', ['withscores' => true]); } } $rResults = $rMulti->exec(); - $rTotalUsers = array(); + $rTotalUsers = []; $i = 0; foreach (array_keys($rServers) as $rServerID) { if ($rServers[$rServerID]['server_online']) { diff --git a/src/Cli/Commands/XcvmCoreCommand.php b/src/Cli/Commands/XcvmCoreCommand.php index b4298043..761d6bf2 100644 --- a/src/Cli/Commands/XcvmCoreCommand.php +++ b/src/Cli/Commands/XcvmCoreCommand.php @@ -38,7 +38,6 @@ use XcVm\Core\Updates\UpdateChannels; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class XcvmCoreCommand implements CommandInterface { - /** Branch of the binaries repo that carries the committed extension tree. */ private const BIN_BRANCH = 'main'; diff --git a/src/Cli/CronJobs/ActivityCronJob.php b/src/Cli/CronJobs/ActivityCronJob.php index 7ec45be2..d55aca78 100644 --- a/src/Cli/CronJobs/ActivityCronJob.php +++ b/src/Cli/CronJobs/ActivityCronJob.php @@ -17,96 +17,96 @@ use XcVm\Infrastructure\Database\DatabaseAware; */ class ActivityCronJob implements CommandInterface { - use DatabaseAware; - use CronTrait; + use DatabaseAware; + use CronTrait; - public function getName(): string { - return 'cron:activity'; - } + public function getName(): string { + return 'cron:activity'; + } - public function getDescription(): string { - return 'Cron: import user activity logs into DB'; - } + public function getDescription(): string { + return 'Cron: import user activity logs into DB'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->initCron('XC_VM[Activity]'); - $this->loadCron(); + $this->initCron('XC_VM[Activity]'); + $this->loadCron(); - return 0; - } + return 0; + } - private function loadCron(): void { - $db = self::db(); + private function loadCron(): void { + $db = self::db(); - $rLogFile = LOGS_TMP_PATH . 'activity'; - $rUpdateQuery = $rQuery = ''; - $rUpdates = array(); - $rCount = 0; + $rLogFile = LOGS_TMP_PATH . 'activity'; + $rUpdateQuery = $rQuery = ''; + $rUpdates = []; + $rCount = 0; - if (!file_exists($rLogFile)) { - return; - } + if (!file_exists($rLogFile)) { + return; + } - list($rQuery, $rUpdates, $rCount) = $this->parseLog($rLogFile); - unlink($rLogFile); + list($rQuery, $rUpdates, $rCount) = $this->parseLog($rLogFile); + unlink($rLogFile); - if (0 >= $rCount) { - return; - } + if (0 >= $rCount) { + return; + } - $rQuery = rtrim($rQuery, ','); - if (empty($rQuery)) { - return; - } + $rQuery = rtrim($rQuery, ','); + if (empty($rQuery)) { + return; + } - if (!$db->query('INSERT INTO `lines_activity` (`server_id`,`proxy_id`,`user_id`,`isp`,`external_device`,`stream_id`,`date_start`,`user_agent`,`user_ip`,`date_end`,`container`,`geoip_country_code`,`divergence`,`hmac_id`,`hmac_identifier`) VALUES ' . $rQuery)) { - return; - } + if (!$db->query('INSERT INTO `lines_activity` (`server_id`,`proxy_id`,`user_id`,`isp`,`external_device`,`stream_id`,`date_start`,`user_agent`,`user_ip`,`date_end`,`container`,`geoip_country_code`,`divergence`,`hmac_id`,`hmac_identifier`) VALUES ' . $rQuery)) { + return; + } - $rFirstID = $db->last_insert_id(); - $i = 0; - while ($i < $rCount) { - $rUpdateQuery .= '(' . $rUpdates[$i][0] . ',' . $db->escape($rUpdates[$i][1]) . ',' . ($rFirstID + $i) . ',' . $db->escape($rUpdates[$i][2]) . '),'; - $i++; - } + $rFirstID = $db->last_insert_id(); + $i = 0; + while ($i < $rCount) { + $rUpdateQuery .= '(' . $rUpdates[$i][0] . ',' . $db->escape($rUpdates[$i][1]) . ',' . ($rFirstID + $i) . ',' . $db->escape($rUpdates[$i][2]) . '),'; + $i++; + } - $rUpdateQuery = rtrim($rUpdateQuery, ','); - if (!empty($rUpdateQuery)) { - $db->query('INSERT INTO `lines`(`id`,`last_ip`,`last_activity`,`last_activity_array`) VALUES ' . $rUpdateQuery . ' ON DUPLICATE KEY UPDATE `id`=VALUES(`id`), `last_ip`=VALUES(`last_ip`), `last_activity`=VALUES(`last_activity`), `last_activity_array`=VALUES(`last_activity_array`);'); - } - } + $rUpdateQuery = rtrim($rUpdateQuery, ','); + if (!empty($rUpdateQuery)) { + $db->query('INSERT INTO `lines`(`id`,`last_ip`,`last_activity`,`last_activity_array`) VALUES ' . $rUpdateQuery . ' ON DUPLICATE KEY UPDATE `id`=VALUES(`id`), `last_ip`=VALUES(`last_ip`), `last_activity`=VALUES(`last_activity`), `last_activity_array`=VALUES(`last_activity_array`);'); + } + } - private function parseLog(string $rFile): array { - $db = self::db(); - $rQuery = ''; - $rUpdates = array(); - $rCount = 0; + private function parseLog(string $rFile): array { + $db = self::db(); + $rQuery = ''; + $rUpdates = []; + $rCount = 0; - if (!file_exists($rFile)) { - return array($rQuery, $rUpdates, $rCount); - } + if (!file_exists($rFile)) { + return [$rQuery, $rUpdates, $rCount]; + } - $rFP = fopen($rFile, 'r'); - while (!feof($rFP)) { - $rLine = trim(fgets($rFP)); - if (!empty($rLine)) { - $rLine = json_decode(base64_decode($rLine), true); - if (!($rLine['server_id'] && $rLine['user_id'] && $rLine['stream_id'] && $rLine['user_ip'])) { - break; - } - $rUpdates[] = array($rLine['user_id'], $rLine['user_ip'], json_encode(array('date_end' => $rLine['date_end'], 'stream_id' => $rLine['stream_id']))); - $rLine = array_map(array($db, 'escape'), $rLine); - $rQuery .= '(' . $rLine['server_id'] . ',' . $rLine['proxy_id'] . ',' . $rLine['user_id'] . ',' . $rLine['isp'] . ',' . $rLine['external_device'] . ',' . $rLine['stream_id'] . ',' . $rLine['date_start'] . ',' . $rLine['user_agent'] . ',' . $rLine['user_ip'] . ',' . $rLine['date_end'] . ',' . $rLine['container'] . ',' . $rLine['geoip_country_code'] . ',' . $rLine['divergence'] . ',' . $rLine['hmac_id'] . ',' . $rLine['hmac_identifier'] . '),'; - $rCount++; - break; - } - } - fclose($rFP); + $rFP = fopen($rFile, 'r'); + while (!feof($rFP)) { + $rLine = trim(fgets($rFP)); + if (!empty($rLine)) { + $rLine = json_decode(base64_decode($rLine), true); + if (!($rLine['server_id'] && $rLine['user_id'] && $rLine['stream_id'] && $rLine['user_ip'])) { + break; + } + $rUpdates[] = [$rLine['user_id'], $rLine['user_ip'], json_encode(['date_end' => $rLine['date_end'], 'stream_id' => $rLine['stream_id']])]; + $rLine = array_map([$db, 'escape'], $rLine); + $rQuery .= '(' . $rLine['server_id'] . ',' . $rLine['proxy_id'] . ',' . $rLine['user_id'] . ',' . $rLine['isp'] . ',' . $rLine['external_device'] . ',' . $rLine['stream_id'] . ',' . $rLine['date_start'] . ',' . $rLine['user_agent'] . ',' . $rLine['user_ip'] . ',' . $rLine['date_end'] . ',' . $rLine['container'] . ',' . $rLine['geoip_country_code'] . ',' . $rLine['divergence'] . ',' . $rLine['hmac_id'] . ',' . $rLine['hmac_identifier'] . '),'; + $rCount++; + break; + } + } + fclose($rFP); - return array($rQuery, $rUpdates, $rCount); - } + return [$rQuery, $rUpdates, $rCount]; + } } diff --git a/src/Cli/CronJobs/BackupsCronJob.php b/src/Cli/CronJobs/BackupsCronJob.php index 2ced3f5d..261ca786 100644 --- a/src/Cli/CronJobs/BackupsCronJob.php +++ b/src/Cli/CronJobs/BackupsCronJob.php @@ -19,113 +19,113 @@ use XcVm\Domain\Server\ServerRepository; */ class BackupsCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:backups'; - } + public function getName(): string { + return 'cron:backups'; + } - public function getDescription(): string { - return 'Cron: automatic database backup (local + Dropbox)'; - } + public function getDescription(): string { + return 'Cron: automatic database backup (local + Dropbox)'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - ini_set('display_errors', 1); - ini_set('display_startup_errors', 1); - error_reporting(32757); + ini_set('display_errors', 1); + ini_set('display_startup_errors', 1); + error_reporting(32757); - if (!ServerRepository::getAll()[SERVER_ID]['is_main']) { - echo 'Please run on main server.' . "\n"; - return 1; - } + if (!ServerRepository::getAll()[SERVER_ID]['is_main']) { + echo 'Please run on main server.' . "\n"; + return 1; + } - $this->setProcessTitle('XC_VM[Backups]'); - $this->acquireCronLock(); + $this->setProcessTitle('XC_VM[Backups]'); + $this->acquireCronLock(); - global $db; + global $db; - $rForce = false; - if (!empty($rArgs[0]) && intval($rArgs[0]) == 1) { - $rForce = true; - } + $rForce = false; + if (!empty($rArgs[0]) && intval($rArgs[0]) == 1) { + $rForce = true; + } - $rBackups = SettingsManager::get('automatic_backups'); - $rLastBackup = intval(SettingsManager::get('last_backup')); - $rPeriod = array('hourly' => 3600, 'daily' => 86400, 'weekly' => 604800, 'monthly' => 2419200); + $rBackups = SettingsManager::get('automatic_backups'); + $rLastBackup = intval(SettingsManager::get('last_backup')); + $rPeriod = ['hourly' => 3600, 'daily' => 86400, 'weekly' => 604800, 'monthly' => 2419200]; - if (!$rForce) { - $rPID = getmypid(); - if (file_exists('/proc/' . SettingsManager::get('backups_pid')) && 0 < strlen(SettingsManager::get('backups_pid'))) { - return 0; - } - $db->query('UPDATE `settings` SET `backups_pid` = ?;', $rPID); - } + if (!$rForce) { + $rPID = getmypid(); + if (file_exists('/proc/' . SettingsManager::get('backups_pid')) && 0 < strlen(SettingsManager::get('backups_pid'))) { + return 0; + } + $db->query('UPDATE `settings` SET `backups_pid` = ?;', $rPID); + } - if (isset($rBackups) && $rBackups != 'off' || $rForce) { - if ($rLastBackup + $rPeriod[$rBackups] <= time() || $rForce) { - if (!$rForce) { - $db->query('UPDATE `settings` SET `last_backup` = ?;', time()); - } - $db->close_mysql(); - $rFilename = MAIN_HOME . 'backups/backup_' . date('Y-m-d_H:i:s') . '.sql'; + if (isset($rBackups) && $rBackups != 'off' || $rForce) { + if ($rLastBackup + $rPeriod[$rBackups] <= time() || $rForce) { + if (!$rForce) { + $db->query('UPDATE `settings` SET `last_backup` = ?;', time()); + } + $db->close_mysql(); + $rFilename = MAIN_HOME . 'backups/backup_' . date('Y-m-d_H:i:s') . '.sql'; - BackupService::create($rFilename); + BackupService::create($rFilename); - if (0 < filesize($rFilename)) { - if (SettingsManager::get('dropbox_remote')) { - file_put_contents($rFilename . '.uploading', time()); - $rResponse = BackupService::uploadRemote(basename($rFilename), $rFilename); - if (!isset($rResponse->error)) { - $rResponse = json_decode(json_encode($rResponse, JSON_UNESCAPED_UNICODE), true); - if (!(isset($rResponse['size']) && intval($rResponse['size']) == filesize($rFilename))) { - $rError = 'Failed to upload'; - file_put_contents($rFilename . '.error', $rError); - } - } else { - try { - $rError = json_decode(explode(', in apiCall', $rResponse->error->getMessage())[0], true)['error_summary']; - } catch (\exception $e) { - $rError = 'Unknown error'; - } - file_put_contents($rFilename . '.error', $rError); - } - unlink($rFilename . '.uploading'); - } - } else { - unlink($rFilename); - } - } - } + if (0 < filesize($rFilename)) { + if (SettingsManager::get('dropbox_remote')) { + file_put_contents($rFilename . '.uploading', time()); + $rResponse = BackupService::uploadRemote(basename($rFilename), $rFilename); + if (!isset($rResponse->error)) { + $rResponse = json_decode(json_encode($rResponse, JSON_UNESCAPED_UNICODE), true); + if (!(isset($rResponse['size']) && intval($rResponse['size']) == filesize($rFilename))) { + $rError = 'Failed to upload'; + file_put_contents($rFilename . '.error', $rError); + } + } else { + try { + $rError = json_decode(explode(', in apiCall', $rResponse->error->getMessage())[0], true)['error_summary']; + } catch (\exception $e) { + $rError = 'Unknown error'; + } + file_put_contents($rFilename . '.error', $rError); + } + unlink($rFilename . '.uploading'); + } + } else { + unlink($rFilename); + } + } + } - $rBackups = BackupService::getLocal(); - if (intval(SettingsManager::get('backups_to_keep')) < count($rBackups) && 0 < intval(SettingsManager::get('backups_to_keep'))) { - $rDelete = array_slice($rBackups, 0, count($rBackups) - intval(SettingsManager::get('backups_to_keep'))); - foreach ($rDelete as $rItem) { - if (file_exists(MAIN_HOME . 'backups/' . $rItem['filename'])) { - unlink(MAIN_HOME . 'backups/' . $rItem['filename']); - } - } - } + $rBackups = BackupService::getLocal(); + if (intval(SettingsManager::get('backups_to_keep')) < count($rBackups) && 0 < intval(SettingsManager::get('backups_to_keep'))) { + $rDelete = array_slice($rBackups, 0, count($rBackups) - intval(SettingsManager::get('backups_to_keep'))); + foreach ($rDelete as $rItem) { + if (file_exists(MAIN_HOME . 'backups/' . $rItem['filename'])) { + unlink(MAIN_HOME . 'backups/' . $rItem['filename']); + } + } + } - if (SettingsManager::get('dropbox_remote')) { - $rRemoteBackups = BackupService::getRemote(); - if (intval(SettingsManager::get('dropbox_keep')) < count($rRemoteBackups) && 0 < intval(SettingsManager::get('dropbox_keep'))) { - $rDelete = array_slice($rRemoteBackups, 0, count($rRemoteBackups) - intval(SettingsManager::get('dropbox_keep'))); - foreach ($rDelete as $rItem) { - try { - BackupService::deleteRemote($rItem['path']); - } catch (\exception $e) { - } - } - } - } + if (SettingsManager::get('dropbox_remote')) { + $rRemoteBackups = BackupService::getRemote(); + if (intval(SettingsManager::get('dropbox_keep')) < count($rRemoteBackups) && 0 < intval(SettingsManager::get('dropbox_keep'))) { + $rDelete = array_slice($rRemoteBackups, 0, count($rRemoteBackups) - intval(SettingsManager::get('dropbox_keep'))); + foreach ($rDelete as $rItem) { + try { + BackupService::deleteRemote($rItem['path']); + } catch (\exception $e) { + } + } + } + } - @unlink($this->rIdentifier); + @unlink($this->rIdentifier); - return 0; - } + return 0; + } } diff --git a/src/Cli/CronJobs/CacheCronJob.php b/src/Cli/CronJobs/CacheCronJob.php index 3edc4b77..6acca718 100644 --- a/src/Cli/CronJobs/CacheCronJob.php +++ b/src/Cli/CronJobs/CacheCronJob.php @@ -24,279 +24,287 @@ use XcVm\Infrastructure\Database\DatabaseAware; */ class CacheCronJob implements CommandInterface { - use DatabaseAware; - use CronTrait; + use DatabaseAware; + use CronTrait; - public function getName(): string { - return 'cron:cache'; - } + public function getName(): string { + return 'cron:cache'; + } - public function getDescription(): string { - return 'Cron: generate file cache (settings, bouquets, servers, blocked, categories, etc.)'; - } + public function getDescription(): string { + return 'Cron: generate file cache (settings, bouquets, servers, blocked, categories, etc.)'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - ini_set('memory_limit', -1); + ini_set('memory_limit', -1); - $rStartup = false; - if (!empty($rArgs[0])) { - $rStartup = true; - } + $rStartup = false; + if (!empty($rArgs[0])) { + $rStartup = true; + } - $this->setProcessTitle('XC_VM[Cache Builder]'); - $this->acquireCronLock(); + $this->setProcessTitle('XC_VM[Cache Builder]'); + $this->acquireCronLock(); - $this->loadCron($rStartup); + $this->loadCron($rStartup); - return 0; - } + return 0; + } - private function loadCron(bool $rStartup): void { - $db = self::db(); - if (!defined('CACHE_TMP_PATH')) { - exit(); - } + private function loadCron(bool $rStartup): void { + $db = self::db(); + if (!defined('CACHE_TMP_PATH')) { + exit(); + } - // Atomic tmp+rename writes: a direct file_put_contents cannot replace a - // cache file left behind by a root-context run (Permission denied spam), - // while rename() only needs a writable directory. - $rCache = new FileCache(CACHE_TMP_PATH); + // Atomic tmp+rename writes: a direct file_put_contents cannot replace a + // cache file left behind by a root-context run (Permission denied spam), + // while rename() only needs a writable directory. + $rCache = new FileCache(CACHE_TMP_PATH); - if ($rStartup && file_exists(CACHE_TMP_PATH . 'settings')) { - echo 'Checking cache readability...' . "\n"; - $rSerialize = igbinary_unserialize(file_get_contents(CACHE_TMP_PATH . 'settings')); - if (!(is_array($rSerialize) && isset($rSerialize['server_name']))) { - echo 'Clearing cache...' . "\n\n"; - foreach (array(STREAMS_TMP_PATH, LINES_TMP_PATH, SERIES_TMP_PATH) as $rTmpPath) { - foreach (scandir($rTmpPath) as $rFile) { - unlink($rTmpPath . $rFile); - } - } - // No sudo: this runs as xc_vm (service boot chowns tmp/ first), - // and xc_vm has no sudoers entry — sudo here would fail silently. - exec('rm -rf ' . TMP_PATH . '*'); - exec('rm -rf ' . SIGNALS_PATH . '*'); - } - } + if ($rStartup && file_exists(CACHE_TMP_PATH . 'settings')) { + echo 'Checking cache readability...' . "\n"; + $rSerialize = igbinary_unserialize(file_get_contents(CACHE_TMP_PATH . 'settings')); + if (!(is_array($rSerialize) && isset($rSerialize['server_name']))) { + echo 'Clearing cache...' . "\n\n"; + foreach ([STREAMS_TMP_PATH, LINES_TMP_PATH, SERIES_TMP_PATH] as $rTmpPath) { + foreach (scandir($rTmpPath) as $rFile) { + unlink($rTmpPath . $rFile); + } + } + // No sudo: this runs as xc_vm (service boot chowns tmp/ first), + // and xc_vm has no sudoers entry — sudo here would fail silently. + exec('rm -rf ' . TMP_PATH . '*'); + exec('rm -rf ' . SIGNALS_PATH . '*'); + } + } - foreach (array(EPG_PATH, VOD_PATH, ARCHIVE_PATH, CREATED_PATH, DELAY_PATH, VIDEO_PATH, PLAYLIST_PATH, CONS_TMP_PATH, CRONS_TMP_PATH, PLAYER_TMP_PATH, CACHE_TMP_PATH, DIVERGENCE_TMP_PATH, FLOOD_TMP_PATH, MINISTRA_TMP_PATH, SIGNALS_TMP_PATH, LOGS_TMP_PATH, WATCH_TMP_PATH, CIDR_TMP_PATH, STREAMS_TMP_PATH, LINES_TMP_PATH, SERIES_TMP_PATH) as $rPath) { - if (!file_exists($rPath)) { - @mkdir($rPath, 0755, true); - if (is_dir($rPath) && function_exists('posix_getpwnam')) { - $rUser = posix_getpwnam('xc_vm'); - if ($rUser) { - chown($rPath, $rUser['uid']); - chgrp($rPath, $rUser['gid']); - } - } - } - } + foreach ([EPG_PATH, VOD_PATH, ARCHIVE_PATH, CREATED_PATH, DELAY_PATH, VIDEO_PATH, PLAYLIST_PATH, CONS_TMP_PATH, CRONS_TMP_PATH, PLAYER_TMP_PATH, CACHE_TMP_PATH, DIVERGENCE_TMP_PATH, FLOOD_TMP_PATH, MINISTRA_TMP_PATH, SIGNALS_TMP_PATH, LOGS_TMP_PATH, WATCH_TMP_PATH, CIDR_TMP_PATH, STREAMS_TMP_PATH, LINES_TMP_PATH, SERIES_TMP_PATH] as $rPath) { + if (!file_exists($rPath)) { + @mkdir($rPath, 0755, true); + if (is_dir($rPath) && function_exists('posix_getpwnam')) { + $rUser = posix_getpwnam('xc_vm'); + if ($rUser) { + chown($rPath, $rUser['uid']); + chgrp($rPath, $rUser['gid']); + } + } + } + } - FileCache::setCache('settings', SettingsRepository::getAll(true)); - FileCache::setCache('bouquets', BouquetService::getAll(true)); - $rServers = ServerRepository::getAll(true); - unset($rServers['php_pids']); - FileCache::setCache('servers', $rServers); - FileCache::setCache('proxy_servers', BlocklistService::getProxyIPs(true)); - FileCache::setCache('blocked_servers', BlocklistService::getBlockedServers(true)); - FileCache::setCache('blocked_isp', BlocklistService::getBlockedISP(true)); - FileCache::setCache('blocked_ua', BlocklistService::getBlockedUA(true)); - FileCache::setCache('blocked_ips', BlocklistService::getBlockedIPs(true)); - FileCache::setCache('allowed_ips', ServerRepository::getAllowedIPs(true)); - FileCache::setCache('categories', CategoryService::getFromDatabase(null, true)); + FileCache::setCache('settings', SettingsRepository::getAll(true)); + FileCache::setCache('bouquets', BouquetService::getAll(true)); + $rServers = ServerRepository::getAll(true); + unset($rServers['php_pids']); + FileCache::setCache('servers', $rServers); + FileCache::setCache('proxy_servers', BlocklistService::getProxyIPs(true)); + FileCache::setCache('blocked_servers', BlocklistService::getBlockedServers(true)); + FileCache::setCache('blocked_isp', BlocklistService::getBlockedISP(true)); + FileCache::setCache('blocked_ua', BlocklistService::getBlockedUA(true)); + FileCache::setCache('blocked_ips', BlocklistService::getBlockedIPs(true)); + FileCache::setCache('allowed_ips', ServerRepository::getAllowedIPs(true)); + FileCache::setCache('categories', CategoryService::getFromDatabase(null, true)); - $rAllServers = ServerRepository::getAll(); - if (!isset($rAllServers[SERVER_ID]) || !$rAllServers[SERVER_ID]['is_main']) { - return; - } + $rAllServers = ServerRepository::getAll(); + if (!isset($rAllServers[SERVER_ID]) || !$rAllServers[SERVER_ID]['is_main']) { + return; + } - // Skip expensive heavy cache rebuild for 5 minutes; lightweight caches above still refresh every run. - $rHeavyMarker = CACHE_TMP_PATH . 'heavy_cache_built'; - if (!$rStartup && file_exists($rHeavyMarker) && (time() - filemtime($rHeavyMarker)) < 300) { - return; - } + // Skip expensive heavy cache rebuild for 5 minutes; lightweight caches above still refresh every run. + $rHeavyMarker = CACHE_TMP_PATH . 'heavy_cache_built'; + if (!$rStartup && file_exists($rHeavyMarker) && (time() - filemtime($rHeavyMarker)) < 300) { + return; + } - $rOutputFormats = array(); - $db->query('SELECT `access_output_id`, `output_key` FROM `output_formats`;'); - foreach ($db->get_rows() as $rRow) { - $rOutputFormats[] = $rRow; - } - $rCache->set('output_formats', $rOutputFormats); + $rOutputFormats = []; + $db->query('SELECT `access_output_id`, `output_key` FROM `output_formats`;'); + foreach ($db->get_rows() as $rRow) { + $rOutputFormats[] = $rRow; + } + $rCache->set('output_formats', $rOutputFormats); - $rHMACKeys = array(); - $db->query('SELECT `id`, `key` FROM `hmac_keys` WHERE `enabled` = 1;'); - foreach ($db->get_rows() as $rRow) { - $rHMACKeys[] = $rRow; - } - $rCache->set('hmac_keys', $rHMACKeys); + $rHMACKeys = []; + $db->query('SELECT `id`, `key` FROM `hmac_keys` WHERE `enabled` = 1;'); + foreach ($db->get_rows() as $rRow) { + $rHMACKeys[] = $rRow; + } + $rCache->set('hmac_keys', $rHMACKeys); - $rRTMPIPs = array(); - $db->query('SELECT `ip`, `password`, `push`, `pull` FROM `rtmp_ips`'); - foreach ($db->get_rows() as $rRow) { - $rRTMPIPs[gethostbyname($rRow['ip'])] = array('password' => $rRow['password'], 'push' => boolval($rRow['push']), 'pull' => boolval($rRow['pull'])); - } - $rCache->set('rtmp_ips', $rRTMPIPs); + $rRTMPIPs = []; + $db->query('SELECT `ip`, `password`, `push`, `pull` FROM `rtmp_ips`'); + foreach ($db->get_rows() as $rRow) { + $rRTMPIPs[gethostbyname($rRow['ip'])] = ['password' => $rRow['password'], 'push' => boolval($rRow['push']), 'pull' => boolval($rRow['pull'])]; + } + $rCache->set('rtmp_ips', $rRTMPIPs); - if (file_exists(BIN_PATH . 'maxmind/cidr.db')) { - exec('ls ' . CIDR_TMP_PATH . ' | wc -l', $rOutput); - if (intval($rOutput[0]) == 0) { - $rDatabase = json_decode(file_get_contents(BIN_PATH . 'maxmind/cidr.db'), true); - foreach ($rDatabase as $rASN => $rData) { - file_put_contents(CIDR_TMP_PATH . $rASN, json_encode($rData)); - } - } - } + if (file_exists(BIN_PATH . 'maxmind/cidr.db')) { + exec('ls ' . CIDR_TMP_PATH . ' | wc -l', $rOutput); + if (intval($rOutput[0]) == 0) { + $rDatabase = json_decode(file_get_contents(BIN_PATH . 'maxmind/cidr.db'), true); + foreach ($rDatabase as $rASN => $rData) { + file_put_contents(CIDR_TMP_PATH . $rASN, json_encode($rData)); + } + } + } - $rChannelOrder = array(); - if (SettingsManager::get('channel_number_type') == 'manual') { - $db->query('SELECT `id`, `order` FROM `streams` ORDER BY `order` ASC;'); - foreach ($db->get_rows() as $rRow) { - $rChannelOrder[] = intval($rRow['id']); - } - } + $rChannelOrder = []; + if (SettingsManager::get('channel_number_type') == 'manual') { + $db->query('SELECT `id`, `order` FROM `streams` ORDER BY `order` ASC;'); + foreach ($db->get_rows() as $rRow) { + $rChannelOrder[] = intval($rRow['id']); + } + } - $rCategoryMap = array(); - $rBouquetMap = array(); - $rStreamIDs = array('channels' => array(), 'radios' => array(), 'movies' => array(), 'episodes' => array(), 'series' => array()); + $rCategoryMap = []; + $rBouquetMap = []; + $rStreamIDs = ['channels' => [], 'radios' => [], 'movies' => [], 'episodes' => [], 'series' => []]; - $db->query('SELECT *, IF(`bouquet_order` > 0, `bouquet_order`, 999) AS `order` FROM `bouquets` ORDER BY `order` ASC;'); - foreach ($db->get_rows(true, 'id') as $rID => $rChannels) { - $rAllowedCategories = array(); + $db->query('SELECT *, IF(`bouquet_order` > 0, `bouquet_order`, 999) AS `order` FROM `bouquets` ORDER BY `order` ASC;'); + foreach ($db->get_rows(true, 'id') as $rID => $rChannels) { + $rAllowedCategories = []; - foreach ((json_decode($rChannels['bouquet_channels'], true) ?: array()) as $rStreamID) { - if (!(0 >= intval($rStreamID) || in_array($rStreamID, $rStreamIDs['channels']))) { - $rStreamIDs['channels'][] = $rStreamID; - } - if (!isset($rBouquetMap[intval($rStreamID)])) { - $rBouquetMap[intval($rStreamID)] = array(); - } - $rBouquetMap[intval($rStreamID)][] = $rID; - } + foreach ((json_decode($rChannels['bouquet_channels'], true) ?: []) as $rStreamID) { + if (!(0 >= intval($rStreamID) || in_array($rStreamID, $rStreamIDs['channels']))) { + $rStreamIDs['channels'][] = $rStreamID; + } + if (!isset($rBouquetMap[intval($rStreamID)])) { + $rBouquetMap[intval($rStreamID)] = []; + } + $rBouquetMap[intval($rStreamID)][] = $rID; + } - foreach ((json_decode($rChannels['bouquet_radios'], true) ?: array()) as $rStreamID) { - if (!(0 >= intval($rStreamID) || in_array($rStreamID, $rStreamIDs['radios']))) { - $rStreamIDs['radios'][] = $rStreamID; - } - if (!isset($rBouquetMap[intval($rStreamID)])) { - $rBouquetMap[intval($rStreamID)] = array(); - } - $rBouquetMap[intval($rStreamID)][] = $rID; - } + foreach ((json_decode($rChannels['bouquet_radios'], true) ?: []) as $rStreamID) { + if (!(0 >= intval($rStreamID) || in_array($rStreamID, $rStreamIDs['radios']))) { + $rStreamIDs['radios'][] = $rStreamID; + } + if (!isset($rBouquetMap[intval($rStreamID)])) { + $rBouquetMap[intval($rStreamID)] = []; + } + $rBouquetMap[intval($rStreamID)][] = $rID; + } - foreach ((json_decode($rChannels['bouquet_movies'], true) ?: array()) as $rStreamID) { - if (!(0 >= intval($rStreamID) || in_array($rStreamID, $rStreamIDs['movies']))) { - $rStreamIDs['movies'][] = $rStreamID; - } - if (!isset($rBouquetMap[intval($rStreamID)])) { - $rBouquetMap[intval($rStreamID)] = array(); - } - $rBouquetMap[intval($rStreamID)][] = $rID; - } + foreach ((json_decode($rChannels['bouquet_movies'], true) ?: []) as $rStreamID) { + if (!(0 >= intval($rStreamID) || in_array($rStreamID, $rStreamIDs['movies']))) { + $rStreamIDs['movies'][] = $rStreamID; + } + if (!isset($rBouquetMap[intval($rStreamID)])) { + $rBouquetMap[intval($rStreamID)] = []; + } + $rBouquetMap[intval($rStreamID)][] = $rID; + } - foreach ((json_decode($rChannels['bouquet_series'], true) ?: array()) as $rSeriesID) { - if (!(0 >= intval($rSeriesID) || in_array($rSeriesID, $rStreamIDs['series']))) { - $db->query('SELECT `stream_id` FROM `streams_episodes` WHERE `series_id` = ? ORDER BY `season_num` ASC, `episode_num` ASC;', $rSeriesID); - foreach ($db->get_rows() as $rEpisode) { - if (0 < intval($rEpisode['stream_id'])) { - $rStreamIDs['episodes'][] = $rEpisode['stream_id']; - } - if (!isset($rBouquetMap[intval($rEpisode['stream_id'])])) { - $rBouquetMap[intval($rEpisode['stream_id'])] = array(); - } - $rBouquetMap[intval($rEpisode['stream_id'])][] = $rID; - } - } - } + foreach ((json_decode($rChannels['bouquet_series'], true) ?: []) as $rSeriesID) { + if (!(0 >= intval($rSeriesID) || in_array($rSeriesID, $rStreamIDs['series']))) { + $db->query('SELECT `stream_id` FROM `streams_episodes` WHERE `series_id` = ? ORDER BY `season_num` ASC, `episode_num` ASC;', $rSeriesID); + foreach ($db->get_rows() as $rEpisode) { + if (0 < intval($rEpisode['stream_id'])) { + $rStreamIDs['episodes'][] = $rEpisode['stream_id']; + } + if (!isset($rBouquetMap[intval($rEpisode['stream_id'])])) { + $rBouquetMap[intval($rEpisode['stream_id'])] = []; + } + $rBouquetMap[intval($rEpisode['stream_id'])][] = $rID; + } + } + } - $rAllChannels = array_map('intval', array_unique(array_merge((json_decode($rChannels['bouquet_channels'], true) ?: array()), (json_decode($rChannels['bouquet_radios'], true) ?: array()), (json_decode($rChannels['bouquet_movies'], true) ?: array())))); - $rAllSeries = array_map('intval', array_unique((json_decode($rChannels['bouquet_series'], true) ?: array()))); + $rAllChannels = array_map('intval', array_unique(array_merge((json_decode($rChannels['bouquet_channels'], true) ?: []), (json_decode($rChannels['bouquet_radios'], true) ?: []), (json_decode($rChannels['bouquet_movies'], true) ?: [])))); + $rAllSeries = array_map('intval', array_unique((json_decode($rChannels['bouquet_series'], true) ?: []))); - if (count($rAllChannels) > 0) { - $db->query('SELECT DISTINCT(`category_id`) AS `category_id` FROM `streams` WHERE `id` IN (' . implode(',', $rAllChannels) . ');'); - foreach ($db->get_rows() as $rRow) { - $rAllowedCategories = array_merge($rAllowedCategories, (json_decode($rRow['category_id'], true) ?: array())); - } - } + if (count($rAllChannels) > 0) { + $db->query('SELECT DISTINCT(`category_id`) AS `category_id` FROM `streams` WHERE `id` IN (' . implode(',', $rAllChannels) . ');'); + foreach ($db->get_rows() as $rRow) { + $rAllowedCategories = array_merge($rAllowedCategories, (json_decode($rRow['category_id'], true) ?: [])); + } + } - if (count($rAllSeries) > 0) { - $db->query('SELECT DISTINCT(`category_id`) AS `category_id` FROM `streams_series` WHERE `id` IN (' . implode(',', $rAllSeries) . ');'); - foreach ($db->get_rows() as $rRow) { - $rAllowedCategories = array_merge($rAllowedCategories, (json_decode($rRow['category_id'], true) ?: array())); - } - } + if (count($rAllSeries) > 0) { + $db->query('SELECT DISTINCT(`category_id`) AS `category_id` FROM `streams_series` WHERE `id` IN (' . implode(',', $rAllSeries) . ');'); + foreach ($db->get_rows() as $rRow) { + $rAllowedCategories = array_merge($rAllowedCategories, (json_decode($rRow['category_id'], true) ?: [])); + } + } - $rCategoryMap[$rID] = array_unique($rAllowedCategories); - } + $rCategoryMap[$rID] = array_unique($rAllowedCategories); + } - if (SettingsManager::get('channel_number_type') != 'manual') { - foreach (array('channels', 'radios', 'movies', 'episodes') as $rKey) { - if (0 < count($rStreamIDs[$rKey])) { - $rWhere = 'AND `id` NOT IN (' . implode(',', array_map('intval', $rStreamIDs[$rKey])) . ')'; - } else { - $rWhere = ''; - } - switch ($rKey) { - case 'channels': $rType = array(1, 3); break; - case 'radios': $rType = array(4); break; - case 'movies': $rType = array(2); break; - case 'episodes': $rType = array(5); break; - } - if (count($rType) > 0) { - $db->query('SELECT `id` FROM `streams` WHERE `type` IN (' . implode(',', $rType) . ') ' . $rWhere . ' ORDER BY `order` ASC;'); - foreach ($db->get_rows() as $rRow) { - $rStreamIDs[$rKey][] = $rRow['id']; - } - } - } + if (SettingsManager::get('channel_number_type') != 'manual') { + foreach (['channels', 'radios', 'movies', 'episodes'] as $rKey) { + if (0 < count($rStreamIDs[$rKey])) { + $rWhere = 'AND `id` NOT IN (' . implode(',', array_map('intval', $rStreamIDs[$rKey])) . ')'; + } else { + $rWhere = ''; + } + switch ($rKey) { + case 'channels': + $rType = [1, 3]; + break; + case 'radios': + $rType = [4]; + break; + case 'movies': + $rType = [2]; + break; + case 'episodes': + $rType = [5]; + break; + } + if (count($rType) > 0) { + $db->query('SELECT `id` FROM `streams` WHERE `type` IN (' . implode(',', $rType) . ') ' . $rWhere . ' ORDER BY `order` ASC;'); + foreach ($db->get_rows() as $rRow) { + $rStreamIDs[$rKey][] = $rRow['id']; + } + } + } - if (SettingsManager::get('vod_sort_newest')) { - $rStreamIDs['movies'] = array(); - $rStreamIDs['episodes'] = array(); - $db->query('SELECT `type`, `id` FROM `streams` WHERE `type` IN (2,5) ORDER BY `added` DESC, `id` DESC;'); - foreach ($db->get_rows() as $rRow) { - $rStreamIDs[array(2 => 'movies', 5 => 'episodes')[$rRow['type']]][] = $rRow['id']; - } - $rSeriesOrder = array(); - $db->query('SELECT `id`, (SELECT MAX(`streams`.`added`) FROM `streams_episodes` LEFT JOIN `streams` ON `streams`.`id` = `streams_episodes`.`stream_id` WHERE `streams_episodes`.`series_id` = `streams_series`.`id`) AS `last_modified_stream` FROM `streams_series` ORDER BY `last_modified_stream` DESC, `last_modified` DESC, `id` DESC;'); - foreach ($db->get_rows() as $rRow) { - $rSeriesOrder[] = intval($rRow['id']); - } - $rCache->set('series_order', $rSeriesOrder); - } + if (SettingsManager::get('vod_sort_newest')) { + $rStreamIDs['movies'] = []; + $rStreamIDs['episodes'] = []; + $db->query('SELECT `type`, `id` FROM `streams` WHERE `type` IN (2,5) ORDER BY `added` DESC, `id` DESC;'); + foreach ($db->get_rows() as $rRow) { + $rStreamIDs[[2 => 'movies', 5 => 'episodes'][$rRow['type']]][] = $rRow['id']; + } + $rSeriesOrder = []; + $db->query('SELECT `id`, (SELECT MAX(`streams`.`added`) FROM `streams_episodes` LEFT JOIN `streams` ON `streams`.`id` = `streams_episodes`.`stream_id` WHERE `streams_episodes`.`series_id` = `streams_series`.`id`) AS `last_modified_stream` FROM `streams_series` ORDER BY `last_modified_stream` DESC, `last_modified` DESC, `id` DESC;'); + foreach ($db->get_rows() as $rRow) { + $rSeriesOrder[] = intval($rRow['id']); + } + $rCache->set('series_order', $rSeriesOrder); + } - foreach (array('channels', 'radios', 'movies', 'episodes') as $rKey) { - foreach ($rStreamIDs[$rKey] as $rStreamID) { - $rChannelOrder[] = intval($rStreamID); - } - } - $rChannelOrder = array_unique($rChannelOrder); - } + foreach (['channels', 'radios', 'movies', 'episodes'] as $rKey) { + foreach ($rStreamIDs[$rKey] as $rStreamID) { + $rChannelOrder[] = intval($rStreamID); + } + } + $rChannelOrder = array_unique($rChannelOrder); + } - $rCategoryChannels = array(); - $db->query('SELECT `id`, `category_id` FROM `streams`;'); - if ($db->dbh && $db->result) { - if ($db->result->rowCount() > 0) { - foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rStreamInfo) { - $rCategoryChannels[$rStreamInfo['id']] = json_decode($rStreamInfo['category_id'] ?? '[]', true); - } - } - } + $rCategoryChannels = []; + $db->query('SELECT `id`, `category_id` FROM `streams`;'); + if ($db->dbh && $db->result) { + if ($db->result->rowCount() > 0) { + foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rStreamInfo) { + $rCategoryChannels[$rStreamInfo['id']] = json_decode($rStreamInfo['category_id'] ?? '[]', true); + } + } + } - $rResellerDomains = array(); - $db->query('SELECT `reseller_dns` FROM `users` WHERE `status` = 1 AND `reseller_dns` IS NOT NULL;'); - foreach ($db->get_rows() as $rRow) { - $rResellerDomains[] = strtolower($rRow['reseller_dns']); - } + $rResellerDomains = []; + $db->query('SELECT `reseller_dns` FROM `users` WHERE `status` = 1 AND `reseller_dns` IS NOT NULL;'); + foreach ($db->get_rows() as $rRow) { + $rResellerDomains[] = strtolower($rRow['reseller_dns']); + } - $rCache->set('reseller_domains', $rResellerDomains); - $rCache->set('channel_order', $rChannelOrder); - $rCache->set('bouquet_map', $rBouquetMap); - $rCache->set('category_map', $rCategoryMap); - (new FileCache(STREAMS_TMP_PATH))->set('channels_categories', $rCategoryChannels); - @touch($rHeavyMarker); - } + $rCache->set('reseller_domains', $rResellerDomains); + $rCache->set('channel_order', $rChannelOrder); + $rCache->set('bouquet_map', $rBouquetMap); + $rCache->set('category_map', $rCategoryMap); + (new FileCache(STREAMS_TMP_PATH))->set('channels_categories', $rCategoryChannels); + @touch($rHeavyMarker); + } } diff --git a/src/Cli/CronJobs/CacheEngineCronJob.php b/src/Cli/CronJobs/CacheEngineCronJob.php index e4159d20..fde4f2ba 100644 --- a/src/Cli/CronJobs/CacheEngineCronJob.php +++ b/src/Cli/CronJobs/CacheEngineCronJob.php @@ -20,618 +20,621 @@ use XcVm\Core\Process\ProcessManager; */ class CacheEngineCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - private $rPID; - private $rSplit = 10000; - private $rThreadCount; - private $rUpdateIDs = []; + private $rPID; - public function getName(): string { - return 'cron:cache_engine'; - } + private $rSplit = 10000; - public function getDescription(): string { - return 'Cron: generate cache for lines, streams, series, groups'; - } + private $rThreadCount; - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + private $rUpdateIDs = []; - $this->rPID = getmypid(); - register_shutdown_function([$this, 'shutdown']); + public function getName(): string { + return 'cron:cache_engine'; + } - ini_set('memory_limit', -1); - ini_set('max_execution_time', 0); + public function getDescription(): string { + return 'Cron: generate cache for lines, streams, series, groups'; + } - SettingsManager::set(SettingsRepository::getAll(true)); - $this->rThreadCount = (SettingsManager::get('cache_thread_count') ?: 10); + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $rType = null; - $rGroupStart = $rGroupMax = null; + $this->rPID = getmypid(); + register_shutdown_function([$this, 'shutdown']); - if (!empty($rArgs[0])) { - $rType = $rArgs[0]; - if ($rType == 'streams_update' || $rType == 'lines_update') { - $this->rUpdateIDs = array_map('intval', explode(',', $rArgs[1] ?? '')); - } else { - if (isset($rArgs[1]) && isset($rArgs[2])) { - $rGroupStart = intval($rArgs[1]); - $rGroupMax = intval($rArgs[2]); - } - } - if ($rType == 'force') { - echo 'Forcing cache regen...' . "\n"; - SettingsManager::update('cache_changes', false); - } - } else { - shell_exec("kill -9 \$(ps aux | grep 'cache_engine' | grep -v grep | grep -v " . $this->rPID . " | awk '{print \$2}')"); - } + ini_set('memory_limit', -1); + ini_set('max_execution_time', 0); - $this->loadCron($rType, $rGroupStart, $rGroupMax); + SettingsManager::set(SettingsRepository::getAll(true)); + $this->rThreadCount = (SettingsManager::get('cache_thread_count') ?: 10); - return 0; - } + $rType = null; + $rGroupStart = $rGroupMax = null; - private function getChangedStreams(): array { - global $db; - $rReturn = ['changes' => [], 'delete' => []]; - $rExisting = []; - $db->query('SELECT `id`, GREATEST(IFNULL(UNIX_TIMESTAMP(`streams`.`updated`), 0), IFNULL(MAX(UNIX_TIMESTAMP(`streams_servers`.`updated`)), 0)) AS `updated` FROM `streams` LEFT JOIN `streams_servers` ON `streams`.`id` = `streams_servers`.`stream_id` GROUP BY `id`;'); - if ($db->dbh && $db->result) { - if ($db->result->rowCount() > 0) { - foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rRow) { - if (!file_exists(STREAMS_TMP_PATH . 'stream_' . $rRow['id']) || (filemtime(STREAMS_TMP_PATH . 'stream_' . $rRow['id']) ?: 0) < $rRow['updated']) { - $rReturn['changes'][] = $rRow['id']; - } - $rExisting[] = $rRow['id']; - } - } - } - $rExisting = array_flip($rExisting); - foreach (glob(STREAMS_TMP_PATH . 'stream_*') as $rFile) { - $rParts = explode('_', $rFile); - $rStreamID = intval(end($rParts)); - if (!isset($rExisting[$rStreamID])) { - $rReturn['delete'][] = $rStreamID; - } - } - return $rReturn; - } + if (!empty($rArgs[0])) { + $rType = $rArgs[0]; + if ($rType == 'streams_update' || $rType == 'lines_update') { + $this->rUpdateIDs = array_map('intval', explode(',', $rArgs[1] ?? '')); + } else { + if (isset($rArgs[1]) && isset($rArgs[2])) { + $rGroupStart = intval($rArgs[1]); + $rGroupMax = intval($rArgs[2]); + } + } + if ($rType == 'force') { + echo 'Forcing cache regen...' . "\n"; + SettingsManager::update('cache_changes', false); + } + } else { + shell_exec("kill -9 \$(ps aux | grep 'cache_engine' | grep -v grep | grep -v " . $this->rPID . " | awk '{print \$2}')"); + } - private function getChangedLines(): array { - global $db; - $rReturn = ['changes' => [], 'delete_i' => [], 'delete_c' => [], 'delete_t' => []]; - $cacheMemoryAllocation = glob(LINES_TMP_PATH . 'line_i_*'); - $cacheFailureHandler = glob(LINES_TMP_PATH . 'line_c_*'); - $cacheSuccessIndicator = glob(LINES_TMP_PATH . 'line_t_*'); - $cacheRevalidationCheck = $cacheDataCompression = $cacheDataDecompression = []; - $db->query('SELECT `id`, `username`, `password`, `access_token`, UNIX_TIMESTAMP(`updated`) AS `updated` FROM `lines`;'); - if ($db->dbh && $db->result) { - if ($db->result->rowCount() > 0) { - foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rRow) { - if (!file_exists(LINES_TMP_PATH . 'line_i_' . $rRow['id']) || (filemtime(LINES_TMP_PATH . 'line_i_' . $rRow['id']) ?: 0) < $rRow['updated']) { - $rReturn['changes'][] = $rRow['id']; - } - $cacheRevalidationCheck[] = $rRow['id']; - $cacheDataCompression[] = (SettingsManager::get('case_sensitive_line') ? $rRow['username'] . '_' . $rRow['password'] : strtolower($rRow['username'] . '_' . $rRow['password'])); - if ($rRow['access_token']) { - $cacheDataDecompression[] = $rRow['access_token']; - } - } - } - } - $cacheRevalidationCheck = array_flip($cacheRevalidationCheck); - foreach ($cacheMemoryAllocation as $rFile) { - $rUserID = (intval(explode('line_i_', $rFile, 2)[1]) ?: null); - if ($rUserID && !isset($cacheRevalidationCheck[$rUserID])) { - $rReturn['delete_i'][] = $rUserID; - } - } - $cacheDataCompression = array_flip($cacheDataCompression); - foreach ($cacheFailureHandler as $rFile) { - $cacheExpirationTime = (explode('line_c_', $rFile, 2)[1] ?: null); - if ($cacheExpirationTime && !isset($cacheDataCompression[$cacheExpirationTime])) { - $rReturn['delete_c'][] = $cacheExpirationTime; - } - } - $cacheDataDecompression = array_flip($cacheDataDecompression); - foreach ($cacheSuccessIndicator as $rFile) { - $rToken = (explode('line_t_', $rFile, 2)[1] ?: null); - if ($rToken && !isset($cacheDataDecompression[$rToken])) { - $rReturn['delete_t'][] = $rToken; - } - } - return $rReturn; - } + $this->loadCron($rType, $rGroupStart, $rGroupMax); - private function loadCron($rType, $rGroupStart, $rGroupMax): void { - global $db; - $rStartTime = time(); - if (ProcessManager::isNginxRunning()) { - if (SettingsManager::get('enable_cache') || !empty($this->rUpdateIDs)) { - switch ($rType) { - case 'lines': - $this->generateLines($rGroupStart, $rGroupMax); - break; - case 'lines_update': - $this->generateLines(null, null, $this->rUpdateIDs); - break; - case 'series': - $this->generateSeries($rGroupStart, $rGroupMax); - break; - case 'streams': - $this->generateStreams($rGroupStart, $rGroupMax); - break; - case 'streams_update': - $this->generateStreams(null, null, $this->rUpdateIDs); - break; - case 'groups': - $this->generateGroups(); - break; - case 'lines_per_ip': - $this->generateLinesPerIP(); - break; - case 'theft_detection': - $this->generateTheftDetection(); - break; - default: - $cacheInitTime = $rSeriesCategories = []; - $db->query('SELECT `series_id`, MAX(`streams`.`added`) AS `last_modified` FROM `streams_episodes` LEFT JOIN `streams` ON `streams`.`id` = `streams_episodes`.`stream_id` GROUP BY `series_id`;'); - foreach ($db->get_rows() as $rRow) { - $cacheInitTime[$rRow['series_id']] = $rRow['last_modified']; - } - $db->query('SELECT * FROM `streams_series`;'); - if ($db->result) { - if ($db->result->rowCount() > 0) { - foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rRow) { - if (isset($cacheInitTime[$rRow['id']])) { - $rRow['last_modified'] = $cacheInitTime[$rRow['id']]; - } - $rSeriesCategories[$rRow['id']] = json_decode($rRow['category_id'], true); - file_put_contents(SERIES_TMP_PATH . 'series_' . $rRow['id'], igbinary_serialize($rRow)); - } - } - } - file_put_contents(SERIES_TMP_PATH . 'series_categories', igbinary_serialize($rSeriesCategories)); - $rDelete = ['streams' => [], 'lines_i' => [], 'lines_c' => [], 'lines_t' => []]; - $cacheDataKey = []; - if (SettingsManager::get('cache_changes')) { - $rChanges = $this->getChangedLines(); - $rDelete['lines_i'] = $rChanges['delete_i']; - $rDelete['lines_c'] = $rChanges['delete_c']; - $rDelete['lines_t'] = $rChanges['delete_t']; - if (count($rChanges['changes']) > 0) { - foreach (array_chunk($rChanges['changes'], $this->rSplit) as $rChunk) { - $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "lines_update" "' . implode(',', $rChunk) . '"'; - } - } - } else { - $db->query('SELECT COUNT(*) AS `count` FROM `lines`;'); - $rLinesCount = $db->get_row()['count']; - $cacheValidityCheck = array(); - if ($this->rSplit > 0) { - for ($rStart = 0; $rStart <= $rLinesCount; $rStart += $this->rSplit) { - $cacheValidityCheck[] = $rStart; - } - } - if (!$cacheValidityCheck) { - $cacheValidityCheck = array(0); - } - foreach ($cacheValidityCheck as $rStart) { - $rMax = $this->rSplit; - if ($rLinesCount < $rStart + $rMax) { - $rMax = $rLinesCount - $rStart; - } - $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "lines" ' . $rStart . ' ' . $rMax; - } - } - $db->query('SELECT COUNT(*) AS `count` FROM `streams_episodes` WHERE `stream_id` IN (SELECT `id` FROM `streams` WHERE `type` = 5);'); - $cacheRetrieveMethod = (int) $db->get_row()['count']; - $cacheStoreMethod = []; - if ($cacheRetrieveMethod > 0) { - for ($rStart = 0; $rStart < $cacheRetrieveMethod; $rStart += $this->rSplit) { - $rMax = min($this->rSplit, $cacheRetrieveMethod - $rStart); - $cacheStoreMethod[] = $rStart; - $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "series" ' . $rStart . ' ' . $rMax; - } - } else { - $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "series" 0 0'; - } - if (SettingsManager::get('cache_changes')) { - $rChanges = $this->getChangedStreams(); - $rDelete['streams'] = $rChanges['delete']; - if (count($rChanges['changes']) > 0) { - foreach (array_chunk($rChanges['changes'], $this->rSplit) as $rChunk) { - $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "streams_update" "' . implode(',', $rChunk) . '"'; - } - } - } else { - $db->query('SELECT COUNT(*) AS `count` FROM `streams`;'); - $cacheDeleteMethod = (int) $db->get_row()['count']; - $cacheCleanupTrigger = array(); - if ($this->rSplit > 0) { - for ($rStart = 0; $rStart <= $cacheDeleteMethod; $rStart += $this->rSplit) { - $cacheCleanupTrigger[] = $rStart; - } - } - if (!$cacheCleanupTrigger) { - $cacheCleanupTrigger = array(0); - } - foreach ($cacheCleanupTrigger as $rStart) { - $rMax = $this->rSplit; - if ($cacheDeleteMethod < $rStart + $rMax) { - $rMax = $cacheDeleteMethod - $rStart; - } - $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "streams" ' . $rStart . ' ' . $rMax; - } - } - $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "groups"'; - $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "lines_per_ip"'; - $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "theft_detection"'; - $cacheMetadataKey = new Multithread($cacheDataKey, $this->rThreadCount); - $cacheMetadataKey->run(); - unset($cacheDataKey); - $rSeriesEpisodes = $rSeriesMap = []; - foreach ($cacheStoreMethod as $rStart) { - if (file_exists(SERIES_TMP_PATH . 'series_map_' . $rStart)) { - foreach (igbinary_unserialize(file_get_contents(SERIES_TMP_PATH . 'series_map_' . $rStart)) as $rStreamID => $rSeriesID) { - $rSeriesMap[$rStreamID] = $rSeriesID; - } - unlink(SERIES_TMP_PATH . 'series_map_' . $rStart); - } - if (file_exists(SERIES_TMP_PATH . 'series_episodes_' . $rStart)) { - $rSeasonData = igbinary_unserialize(file_get_contents(SERIES_TMP_PATH . 'series_episodes_' . $rStart)); - foreach (array_keys($rSeasonData) as $rSeriesID) { - if (!isset($rSeriesEpisodes[$rSeriesID])) { - $rSeriesEpisodes[$rSeriesID] = []; - } - foreach (array_keys($rSeasonData[$rSeriesID]) as $rSeasonNum) { - foreach ($rSeasonData[$rSeriesID][$rSeasonNum] as $rEpisode) { - $rSeriesEpisodes[$rSeriesID][$rSeasonNum][] = $rEpisode; - } - } - } - unlink(SERIES_TMP_PATH . 'series_episodes_' . $rStart); - } - } - file_put_contents(SERIES_TMP_PATH . 'series_map', igbinary_serialize($rSeriesMap)); - foreach ($rSeriesEpisodes as $rSeriesID => $rSeasons) { - file_put_contents(SERIES_TMP_PATH . 'episodes_' . $rSeriesID, igbinary_serialize($rSeasons)); - } - if (SettingsManager::get('cache_changes')) { - foreach ($rDelete['streams'] as $rStreamID) { - @unlink(STREAMS_TMP_PATH . 'stream_' . $rStreamID); - } - foreach ($rDelete['lines_i'] as $rUserID) { - @unlink(LINES_TMP_PATH . 'line_i_' . $rUserID); - } - foreach ($rDelete['lines_c'] as $cacheExpirationTime) { - @unlink(LINES_TMP_PATH . 'line_c_' . $cacheExpirationTime); - } - foreach ($rDelete['lines_t'] as $rToken) { - @unlink(LINES_TMP_PATH . 'line_t_' . $rToken); - } - } else { - foreach ([STREAMS_TMP_PATH, LINES_TMP_PATH, SERIES_TMP_PATH] as $rTmpPath) { - foreach (scandir($rTmpPath) as $rFile) { - if ($rFile === '.' || $rFile === '..') { - continue; - } - $rFilePath = $rTmpPath . $rFile; - if (is_file($rFilePath) && filemtime($rFilePath) < $rStartTime - 1) { - unlink($rFilePath); - } - } - } - } - echo 'Cache updated!' . "\n"; - file_put_contents(CACHE_TMP_PATH . 'cache_complete', time()); - $db->query('UPDATE `settings` SET `last_cache` = ?, `last_cache_taken` = ?;', time(), time() - $rStartTime); - break; - } - } else { - echo 'Cache is disabled.' . "\n"; - echo 'Generating group permissions...' . "\n"; - $this->generateGroups(); - echo 'Generating lines per ip...' . "\n"; - $this->generateLinesPerIP(); - echo 'Detecting theft of VOD...' . "\n"; - $this->generateTheftDetection(); - echo 'Clearing old data...' . "\n"; - foreach ([STREAMS_TMP_PATH, LINES_TMP_PATH, SERIES_TMP_PATH] as $rTmpPath) { - foreach (scandir($rTmpPath) as $rFile) { - if ($rFile === '.' || $rFile === '..') { - continue; - } - $rFilePath = $rTmpPath . $rFile; - if (is_file($rFilePath)) { - unlink($rFilePath); - } - } - } - file_put_contents(CACHE_TMP_PATH . 'cache_complete', time()); - exit(); - } - } else { - echo 'XC_VM not running...' . "\n"; - exit(); - } - } + return 0; + } - private function generateLines($rStart = null, $rCount = null, $cacheLockMechanism = []): void { - global $db; - if (is_null($rCount)) { - $rCount = count($cacheLockMechanism); - } - if ($rCount > 0) { - $rSteps = []; - if (!is_null($rStart)) { - $rEnd = $rStart + $rCount - 1; - if ($this->rSplit >= ($rEnd - $rStart + 1)) { - $rSteps = [$rStart]; - } - } else { - $rSteps = [null]; - } - $rExists = []; - foreach ($rSteps as $rStep) { - if (!is_null($rStep)) { - if ($rStart + $rCount < $rStep + $this->rSplit) { - $rMax = ($rStart + $rCount) - $rStep; - } else { - $rMax = $this->rSplit; - } - $db->query('SELECT `id`, `username`, `password`, `exp_date`, `created_at`, `admin_enabled`, `enabled`, `bouquet`, `allowed_outputs`, `max_connections`, `is_trial`, `is_restreamer`, `is_stalker`, `is_mag`, `is_e2`, `is_isplock`, `allowed_ips`, `allowed_ua`, `pair_id`, `force_server_id`, `isp_desc`, `forced_country`, `bypass_ua`, `last_expiration_video`, `access_token`, `mag_devices`.`token` AS `mag_token`, `admin_notes`, `reseller_notes` FROM `lines` LEFT JOIN `mag_devices` ON `mag_devices`.`user_id` = `lines`.`id` LIMIT ' . $rStep . ', ' . $rMax . ';'); - } else { - $db->query('SELECT `id`, `username`, `password`, `exp_date`, `created_at`, `admin_enabled`, `enabled`, `bouquet`, `allowed_outputs`, `max_connections`, `is_trial`, `is_restreamer`, `is_stalker`, `is_mag`, `is_e2`, `is_isplock`, `allowed_ips`, `allowed_ua`, `pair_id`, `force_server_id`, `isp_desc`, `forced_country`, `bypass_ua`, `last_expiration_video`, `access_token`, `mag_devices`.`token` AS `mag_token`, `admin_notes`, `reseller_notes` FROM `lines` LEFT JOIN `mag_devices` ON `mag_devices`.`user_id` = `lines`.`id` WHERE `id` IN (' . implode(',', $cacheLockMechanism) . ');'); - } - if ($db->result) { - if ($db->result->rowCount() > 0) { - foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rUserInfo) { - $rExists[] = $rUserInfo['id']; - file_put_contents(LINES_TMP_PATH . 'line_i_' . $rUserInfo['id'], igbinary_serialize($rUserInfo)); - $rKey = (SettingsManager::get('case_sensitive_line') ? $rUserInfo['username'] . '_' . $rUserInfo['password'] : strtolower($rUserInfo['username'] . '_' . $rUserInfo['password'])); - file_put_contents(LINES_TMP_PATH . 'line_c_' . $rKey, $rUserInfo['id']); - if (!empty($rUserInfo['access_token'])) { - file_put_contents(LINES_TMP_PATH . 'line_t_' . $rUserInfo['access_token'], $rUserInfo['id']); - } - } - } - $db->result = null; - } - } - if (count($cacheLockMechanism) > 0) { - foreach ($cacheLockMechanism as $rForceID) { - if (!in_array($rForceID, $rExists) && file_exists(LINES_TMP_PATH . 'line_i_' . $rForceID)) { - unlink(LINES_TMP_PATH . 'line_i_' . $rForceID); - } - } - } - } - } + private function getChangedStreams(): array { + global $db; + $rReturn = ['changes' => [], 'delete' => []]; + $rExisting = []; + $db->query('SELECT `id`, GREATEST(IFNULL(UNIX_TIMESTAMP(`streams`.`updated`), 0), IFNULL(MAX(UNIX_TIMESTAMP(`streams_servers`.`updated`)), 0)) AS `updated` FROM `streams` LEFT JOIN `streams_servers` ON `streams`.`id` = `streams_servers`.`stream_id` GROUP BY `id`;'); + if ($db->dbh && $db->result) { + if ($db->result->rowCount() > 0) { + foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rRow) { + if (!file_exists(STREAMS_TMP_PATH . 'stream_' . $rRow['id']) || (filemtime(STREAMS_TMP_PATH . 'stream_' . $rRow['id']) ?: 0) < $rRow['updated']) { + $rReturn['changes'][] = $rRow['id']; + } + $rExisting[] = $rRow['id']; + } + } + } + $rExisting = array_flip($rExisting); + foreach (glob(STREAMS_TMP_PATH . 'stream_*') as $rFile) { + $rParts = explode('_', $rFile); + $rStreamID = intval(end($rParts)); + if (!isset($rExisting[$rStreamID])) { + $rReturn['delete'][] = $rStreamID; + } + } + return $rReturn; + } - private function generateStreams($rStart = null, $rCount = null, $cacheLockMechanism = []): void { - global $db; - if (is_null($rCount)) { - $rCount = count($cacheLockMechanism); - } - if ($rCount > 0) { - $rBouquetMap = []; - $rBouquetMapPath = CACHE_TMP_PATH . 'bouquet_map'; - if (file_exists($rBouquetMapPath) && 0 < filesize($rBouquetMapPath)) { - $rBouquetData = @igbinary_unserialize(file_get_contents($rBouquetMapPath)); - if (is_array($rBouquetData)) { - $rBouquetMap = $rBouquetData; - } - } - $rSteps = []; - if (!is_null($rStart)) { - $rEnd = $rStart + $rCount - 1; - if ($this->rSplit >= ($rEnd - $rStart + 1)) { - $rSteps = [$rStart]; - } - } else { - $rSteps = [null]; - } - $rExists = []; - foreach ($rSteps as $rStep) { - if (!is_null($rStep)) { - if ($rStart + $rCount < $rStep + $this->rSplit) { - $rMax = ($rStart + $rCount) - $rStep; - } else { - $rMax = $this->rSplit; - } - $db->query('SELECT t1.id,t1.epg_id,t1.added,t1.allow_record,t1.year,t1.channel_id,t1.movie_properties,t1.stream_source,t1.tv_archive_server_id,t1.vframes_server_id,t1.tv_archive_duration,t1.stream_icon,t1.custom_sid,t1.category_id,t1.stream_display_name,t1.series_no,t1.direct_source,t1.direct_proxy,t2.type_output,t1.target_container,t2.live,t1.rtmp_output,t1.order,t2.type_key,t1.tmdb_id,t1.adaptive_link FROM `streams` t1 INNER JOIN `streams_types` t2 ON t2.type_id = t1.type LIMIT ' . $rStep . ', ' . $rMax . ';'); - } else { - $db->query('SELECT t1.id,t1.epg_id,t1.added,t1.allow_record,t1.year,t1.channel_id,t1.movie_properties,t1.stream_source,t1.tv_archive_server_id,t1.vframes_server_id,t1.tv_archive_duration,t1.stream_icon,t1.custom_sid,t1.category_id,t1.stream_display_name,t1.series_no,t1.direct_source,t1.direct_proxy,t2.type_output,t1.target_container,t2.live,t1.rtmp_output,t1.order,t2.type_key,t1.tmdb_id,t1.adaptive_link FROM `streams` t1 INNER JOIN `streams_types` t2 ON t2.type_id = t1.type WHERE `t1`.`id` IN (' . implode(',', $cacheLockMechanism) . ');'); - } - if ($db->result) { - if ($db->result->rowCount() > 0) { - $rRows = $db->result->fetchAll(\PDO::FETCH_ASSOC); - $rStreamMap = $rStreamIDs = []; - foreach ($rRows as $rRow) { - $rStreamIDs[] = $rRow['id']; - } - if (count($rStreamIDs) > 0) { - if ($db->query('SELECT `stream_id`, `server_id`, `pid`, `to_analyze`, `stream_status`, `monitor_pid`, `on_demand`, `delay_available_at`, `bitrate`, `parent_id`, `on_demand`, `stream_info`, `video_codec`, `audio_codec`, `resolution`, `compatible` FROM `streams_servers` WHERE `stream_id` IN (' . implode(',', $rStreamIDs) . ')')) { - if ($db->result->rowCount() > 0) { - foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rRow) { - $rStreamMap[intval($rRow['stream_id'])][intval($rRow['server_id'])] = $rRow; - } - } - $db->result = null; - } - } - foreach ($rRows as $rStreamInfo) { - $rExists[] = $rStreamInfo['id']; - if (!$rStreamInfo['direct_source']) { - unset($rStreamInfo['stream_source']); - } - $rOutput = ['info' => $rStreamInfo, 'bouquets' => ($rBouquetMap[intval($rStreamInfo['id'])] ?? []), 'servers' => ($rStreamMap[intval($rStreamInfo['id'])] ?? [])]; - file_put_contents(STREAMS_TMP_PATH . 'stream_' . $rStreamInfo['id'], igbinary_serialize($rOutput)); - } - unset($rRows, $rStreamMap, $rStreamIDs); - } - $db->result = null; - } - } - if (count($cacheLockMechanism) > 0) { - foreach ($cacheLockMechanism as $rForceID) { - if (!in_array($rForceID, $rExists) && file_exists(STREAMS_TMP_PATH . 'stream_' . $rForceID)) { - unlink(STREAMS_TMP_PATH . 'stream_' . $rForceID); - } - } - } - } - } + private function getChangedLines(): array { + global $db; + $rReturn = ['changes' => [], 'delete_i' => [], 'delete_c' => [], 'delete_t' => []]; + $cacheMemoryAllocation = glob(LINES_TMP_PATH . 'line_i_*'); + $cacheFailureHandler = glob(LINES_TMP_PATH . 'line_c_*'); + $cacheSuccessIndicator = glob(LINES_TMP_PATH . 'line_t_*'); + $cacheRevalidationCheck = $cacheDataCompression = $cacheDataDecompression = []; + $db->query('SELECT `id`, `username`, `password`, `access_token`, UNIX_TIMESTAMP(`updated`) AS `updated` FROM `lines`;'); + if ($db->dbh && $db->result) { + if ($db->result->rowCount() > 0) { + foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rRow) { + if (!file_exists(LINES_TMP_PATH . 'line_i_' . $rRow['id']) || (filemtime(LINES_TMP_PATH . 'line_i_' . $rRow['id']) ?: 0) < $rRow['updated']) { + $rReturn['changes'][] = $rRow['id']; + } + $cacheRevalidationCheck[] = $rRow['id']; + $cacheDataCompression[] = (SettingsManager::get('case_sensitive_line') ? $rRow['username'] . '_' . $rRow['password'] : strtolower($rRow['username'] . '_' . $rRow['password'])); + if ($rRow['access_token']) { + $cacheDataDecompression[] = $rRow['access_token']; + } + } + } + } + $cacheRevalidationCheck = array_flip($cacheRevalidationCheck); + foreach ($cacheMemoryAllocation as $rFile) { + $rUserID = (intval(explode('line_i_', $rFile, 2)[1]) ?: null); + if ($rUserID && !isset($cacheRevalidationCheck[$rUserID])) { + $rReturn['delete_i'][] = $rUserID; + } + } + $cacheDataCompression = array_flip($cacheDataCompression); + foreach ($cacheFailureHandler as $rFile) { + $cacheExpirationTime = (explode('line_c_', $rFile, 2)[1] ?: null); + if ($cacheExpirationTime && !isset($cacheDataCompression[$cacheExpirationTime])) { + $rReturn['delete_c'][] = $cacheExpirationTime; + } + } + $cacheDataDecompression = array_flip($cacheDataDecompression); + foreach ($cacheSuccessIndicator as $rFile) { + $rToken = (explode('line_t_', $rFile, 2)[1] ?: null); + if ($rToken && !isset($cacheDataDecompression[$rToken])) { + $rReturn['delete_t'][] = $rToken; + } + } + return $rReturn; + } - private function generateSeries($rStart, $rCount): void { - global $db; - $rSeriesMap = []; - $rSeriesEpisodes = []; - if ($rCount > 0) { - if (is_null($rStart)) { - $rSteps = [null]; - } else { - $rEnd = $rStart + $rCount - 1; - $rangeLength = $rEnd - $rStart + 1; - if ($this->rSplit >= $rangeLength) { - $rSteps = [$rStart]; - } else { - $rSteps = range($rStart, $rEnd, $this->rSplit); - } - } - foreach ($rSteps as $rStep) { - if ($rStart + $rCount < $rStep + $this->rSplit) { - $rMax = ($rStart + $rCount) - $rStep; - } else { - $rMax = $this->rSplit; - } - $db->query('SELECT `stream_id`, `series_id`, `season_num`, `episode_num` FROM `streams_episodes` WHERE `stream_id` IN (SELECT `id` FROM `streams` WHERE `type` = 5) ORDER BY `series_id` ASC, `season_num` ASC, `episode_num` ASC LIMIT ' . $rStep . ', ' . $rMax . ';'); - foreach ($db->get_rows() as $rRow) { - if ($rRow['stream_id'] && $rRow['series_id']) { - $rSeriesMap[intval($rRow['stream_id'])] = intval($rRow['series_id']); - if (!isset($rSeriesEpisodes[$rRow['series_id']])) { - $rSeriesEpisodes[$rRow['series_id']] = []; - } - $rSeriesEpisodes[$rRow['series_id']][$rRow['season_num']][] = ['episode_num' => $rRow['episode_num'], 'stream_id' => $rRow['stream_id']]; - } - } - } - } - file_put_contents(SERIES_TMP_PATH . 'series_episodes_' . $rStart, igbinary_serialize($rSeriesEpisodes)); - file_put_contents(SERIES_TMP_PATH . 'series_map_' . $rStart, igbinary_serialize($rSeriesMap)); - unset($rSeriesMap); - } + private function loadCron($rType, $rGroupStart, $rGroupMax): void { + global $db; + $rStartTime = time(); + if (ProcessManager::isNginxRunning()) { + if (SettingsManager::get('enable_cache') || !empty($this->rUpdateIDs)) { + switch ($rType) { + case 'lines': + $this->generateLines($rGroupStart, $rGroupMax); + break; + case 'lines_update': + $this->generateLines(null, null, $this->rUpdateIDs); + break; + case 'series': + $this->generateSeries($rGroupStart, $rGroupMax); + break; + case 'streams': + $this->generateStreams($rGroupStart, $rGroupMax); + break; + case 'streams_update': + $this->generateStreams(null, null, $this->rUpdateIDs); + break; + case 'groups': + $this->generateGroups(); + break; + case 'lines_per_ip': + $this->generateLinesPerIP(); + break; + case 'theft_detection': + $this->generateTheftDetection(); + break; + default: + $cacheInitTime = $rSeriesCategories = []; + $db->query('SELECT `series_id`, MAX(`streams`.`added`) AS `last_modified` FROM `streams_episodes` LEFT JOIN `streams` ON `streams`.`id` = `streams_episodes`.`stream_id` GROUP BY `series_id`;'); + foreach ($db->get_rows() as $rRow) { + $cacheInitTime[$rRow['series_id']] = $rRow['last_modified']; + } + $db->query('SELECT * FROM `streams_series`;'); + if ($db->result) { + if ($db->result->rowCount() > 0) { + foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rRow) { + if (isset($cacheInitTime[$rRow['id']])) { + $rRow['last_modified'] = $cacheInitTime[$rRow['id']]; + } + $rSeriesCategories[$rRow['id']] = json_decode($rRow['category_id'], true); + file_put_contents(SERIES_TMP_PATH . 'series_' . $rRow['id'], igbinary_serialize($rRow)); + } + } + } + file_put_contents(SERIES_TMP_PATH . 'series_categories', igbinary_serialize($rSeriesCategories)); + $rDelete = ['streams' => [], 'lines_i' => [], 'lines_c' => [], 'lines_t' => []]; + $cacheDataKey = []; + if (SettingsManager::get('cache_changes')) { + $rChanges = $this->getChangedLines(); + $rDelete['lines_i'] = $rChanges['delete_i']; + $rDelete['lines_c'] = $rChanges['delete_c']; + $rDelete['lines_t'] = $rChanges['delete_t']; + if (count($rChanges['changes']) > 0) { + foreach (array_chunk($rChanges['changes'], $this->rSplit) as $rChunk) { + $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "lines_update" "' . implode(',', $rChunk) . '"'; + } + } + } else { + $db->query('SELECT COUNT(*) AS `count` FROM `lines`;'); + $rLinesCount = $db->get_row()['count']; + $cacheValidityCheck = []; + if ($this->rSplit > 0) { + for ($rStart = 0; $rStart <= $rLinesCount; $rStart += $this->rSplit) { + $cacheValidityCheck[] = $rStart; + } + } + if (!$cacheValidityCheck) { + $cacheValidityCheck = [0]; + } + foreach ($cacheValidityCheck as $rStart) { + $rMax = $this->rSplit; + if ($rLinesCount < $rStart + $rMax) { + $rMax = $rLinesCount - $rStart; + } + $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "lines" ' . $rStart . ' ' . $rMax; + } + } + $db->query('SELECT COUNT(*) AS `count` FROM `streams_episodes` WHERE `stream_id` IN (SELECT `id` FROM `streams` WHERE `type` = 5);'); + $cacheRetrieveMethod = (int) $db->get_row()['count']; + $cacheStoreMethod = []; + if ($cacheRetrieveMethod > 0) { + for ($rStart = 0; $rStart < $cacheRetrieveMethod; $rStart += $this->rSplit) { + $rMax = min($this->rSplit, $cacheRetrieveMethod - $rStart); + $cacheStoreMethod[] = $rStart; + $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "series" ' . $rStart . ' ' . $rMax; + } + } else { + $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "series" 0 0'; + } + if (SettingsManager::get('cache_changes')) { + $rChanges = $this->getChangedStreams(); + $rDelete['streams'] = $rChanges['delete']; + if (count($rChanges['changes']) > 0) { + foreach (array_chunk($rChanges['changes'], $this->rSplit) as $rChunk) { + $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "streams_update" "' . implode(',', $rChunk) . '"'; + } + } + } else { + $db->query('SELECT COUNT(*) AS `count` FROM `streams`;'); + $cacheDeleteMethod = (int) $db->get_row()['count']; + $cacheCleanupTrigger = []; + if ($this->rSplit > 0) { + for ($rStart = 0; $rStart <= $cacheDeleteMethod; $rStart += $this->rSplit) { + $cacheCleanupTrigger[] = $rStart; + } + } + if (!$cacheCleanupTrigger) { + $cacheCleanupTrigger = [0]; + } + foreach ($cacheCleanupTrigger as $rStart) { + $rMax = $this->rSplit; + if ($cacheDeleteMethod < $rStart + $rMax) { + $rMax = $cacheDeleteMethod - $rStart; + } + $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "streams" ' . $rStart . ' ' . $rMax; + } + } + $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "groups"'; + $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "lines_per_ip"'; + $cacheDataKey[] = PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:cache_engine "theft_detection"'; + $cacheMetadataKey = new Multithread($cacheDataKey, $this->rThreadCount); + $cacheMetadataKey->run(); + unset($cacheDataKey); + $rSeriesEpisodes = $rSeriesMap = []; + foreach ($cacheStoreMethod as $rStart) { + if (file_exists(SERIES_TMP_PATH . 'series_map_' . $rStart)) { + foreach (igbinary_unserialize(file_get_contents(SERIES_TMP_PATH . 'series_map_' . $rStart)) as $rStreamID => $rSeriesID) { + $rSeriesMap[$rStreamID] = $rSeriesID; + } + unlink(SERIES_TMP_PATH . 'series_map_' . $rStart); + } + if (file_exists(SERIES_TMP_PATH . 'series_episodes_' . $rStart)) { + $rSeasonData = igbinary_unserialize(file_get_contents(SERIES_TMP_PATH . 'series_episodes_' . $rStart)); + foreach (array_keys($rSeasonData) as $rSeriesID) { + if (!isset($rSeriesEpisodes[$rSeriesID])) { + $rSeriesEpisodes[$rSeriesID] = []; + } + foreach (array_keys($rSeasonData[$rSeriesID]) as $rSeasonNum) { + foreach ($rSeasonData[$rSeriesID][$rSeasonNum] as $rEpisode) { + $rSeriesEpisodes[$rSeriesID][$rSeasonNum][] = $rEpisode; + } + } + } + unlink(SERIES_TMP_PATH . 'series_episodes_' . $rStart); + } + } + file_put_contents(SERIES_TMP_PATH . 'series_map', igbinary_serialize($rSeriesMap)); + foreach ($rSeriesEpisodes as $rSeriesID => $rSeasons) { + file_put_contents(SERIES_TMP_PATH . 'episodes_' . $rSeriesID, igbinary_serialize($rSeasons)); + } + if (SettingsManager::get('cache_changes')) { + foreach ($rDelete['streams'] as $rStreamID) { + @unlink(STREAMS_TMP_PATH . 'stream_' . $rStreamID); + } + foreach ($rDelete['lines_i'] as $rUserID) { + @unlink(LINES_TMP_PATH . 'line_i_' . $rUserID); + } + foreach ($rDelete['lines_c'] as $cacheExpirationTime) { + @unlink(LINES_TMP_PATH . 'line_c_' . $cacheExpirationTime); + } + foreach ($rDelete['lines_t'] as $rToken) { + @unlink(LINES_TMP_PATH . 'line_t_' . $rToken); + } + } else { + foreach ([STREAMS_TMP_PATH, LINES_TMP_PATH, SERIES_TMP_PATH] as $rTmpPath) { + foreach (scandir($rTmpPath) as $rFile) { + if ($rFile === '.' || $rFile === '..') { + continue; + } + $rFilePath = $rTmpPath . $rFile; + if (is_file($rFilePath) && filemtime($rFilePath) < $rStartTime - 1) { + unlink($rFilePath); + } + } + } + } + echo 'Cache updated!' . "\n"; + file_put_contents(CACHE_TMP_PATH . 'cache_complete', time()); + $db->query('UPDATE `settings` SET `last_cache` = ?, `last_cache_taken` = ?;', time(), time() - $rStartTime); + break; + } + } else { + echo 'Cache is disabled.' . "\n"; + echo 'Generating group permissions...' . "\n"; + $this->generateGroups(); + echo 'Generating lines per ip...' . "\n"; + $this->generateLinesPerIP(); + echo 'Detecting theft of VOD...' . "\n"; + $this->generateTheftDetection(); + echo 'Clearing old data...' . "\n"; + foreach ([STREAMS_TMP_PATH, LINES_TMP_PATH, SERIES_TMP_PATH] as $rTmpPath) { + foreach (scandir($rTmpPath) as $rFile) { + if ($rFile === '.' || $rFile === '..') { + continue; + } + $rFilePath = $rTmpPath . $rFile; + if (is_file($rFilePath)) { + unlink($rFilePath); + } + } + } + file_put_contents(CACHE_TMP_PATH . 'cache_complete', time()); + exit(); + } + } else { + echo 'XC_VM not running...' . "\n"; + exit(); + } + } - private function generateGroups(): void { - global $db; - $db->query('SELECT `group_id` FROM `users_groups`;'); - foreach ($db->get_rows() as $rGroup) { - $rBouquets = $rReturn = []; - $db->query("SELECT * FROM `users_packages` WHERE JSON_CONTAINS(`groups`, ?, '\$');", $rGroup['group_id']); - foreach ($db->get_rows() as $rRow) { - foreach (json_decode($rRow['bouquets'], true) as $rID) { - if (!in_array($rID, $rBouquets)) { - $rBouquets[] = $rID; - } - } - if ($rRow['is_line']) { - $rReturn['create_line'] = true; - } - if ($rRow['is_mag']) { - $rReturn['create_mag'] = true; - } - if ($rRow['is_e2']) { - $rReturn['create_enigma'] = true; - } - } - if (count($rBouquets) > 0) { - $db->query('SELECT * FROM `bouquets` WHERE `id` IN (' . implode(',', array_map('intval', $rBouquets)) . ');'); - $rSeriesIDs = []; - $rStreamIDs = []; - foreach ($db->get_rows() as $rRow) { - if ($rRow['bouquet_channels']) { - $rStreamIDs = array_merge($rStreamIDs, json_decode($rRow['bouquet_channels'], true)); - } - if ($rRow['bouquet_movies']) { - $rStreamIDs = array_merge($rStreamIDs, json_decode($rRow['bouquet_movies'], true)); - } - if ($rRow['bouquet_radios']) { - $rStreamIDs = array_merge($rStreamIDs, json_decode($rRow['bouquet_radios'], true)); - } - foreach (json_decode($rRow['bouquet_series'], true) as $rSeriesID) { - $rSeriesIDs[] = $rSeriesID; - $db->query('SELECT `stream_id` FROM `streams_episodes` WHERE `series_id` = ?;', $rSeriesID); - foreach ($db->get_rows() as $rEpisode) { - $rStreamIDs[] = $rEpisode['stream_id']; - } - } - } - $rReturn['stream_ids'] = array_unique($rStreamIDs); - $rReturn['series_ids'] = array_unique($rSeriesIDs); - $rCategories = []; - if (count($rReturn['stream_ids']) > 0) { - $db->query('SELECT DISTINCT(`category_id`) AS `category_id` FROM `streams` WHERE `id` IN (' . implode(',', array_map('intval', $rReturn['stream_ids'])) . ');'); - foreach ($db->get_rows() as $rRow) { - if ($rRow['category_id']) { - $rCategories = array_merge($rCategories, json_decode($rRow['category_id'], true)); - } - } - } - if (count($rReturn['series_ids']) > 0) { - $db->query('SELECT DISTINCT(`category_id`) AS `category_id` FROM `streams_series` WHERE `id` IN (' . implode(',', array_map('intval', $rReturn['series_ids'])) . ');'); - foreach ($db->get_rows() as $rRow) { - if ($rRow['category_id']) { - $rCategories = array_merge($rCategories, json_decode($rRow['category_id'], true)); - } - } - } - $rReturn['category_ids'] = array_unique($rCategories); - } - file_put_contents(CACHE_TMP_PATH . 'permissions_' . intval($rGroup['group_id']), igbinary_serialize($rReturn)); - } - } + private function generateLines($rStart = null, $rCount = null, $cacheLockMechanism = []): void { + global $db; + if (is_null($rCount)) { + $rCount = count($cacheLockMechanism); + } + if ($rCount > 0) { + $rSteps = []; + if (!is_null($rStart)) { + $rEnd = $rStart + $rCount - 1; + if ($this->rSplit >= ($rEnd - $rStart + 1)) { + $rSteps = [$rStart]; + } + } else { + $rSteps = [null]; + } + $rExists = []; + foreach ($rSteps as $rStep) { + if (!is_null($rStep)) { + if ($rStart + $rCount < $rStep + $this->rSplit) { + $rMax = ($rStart + $rCount) - $rStep; + } else { + $rMax = $this->rSplit; + } + $db->query('SELECT `id`, `username`, `password`, `exp_date`, `created_at`, `admin_enabled`, `enabled`, `bouquet`, `allowed_outputs`, `max_connections`, `is_trial`, `is_restreamer`, `is_stalker`, `is_mag`, `is_e2`, `is_isplock`, `allowed_ips`, `allowed_ua`, `pair_id`, `force_server_id`, `isp_desc`, `forced_country`, `bypass_ua`, `last_expiration_video`, `access_token`, `mag_devices`.`token` AS `mag_token`, `admin_notes`, `reseller_notes` FROM `lines` LEFT JOIN `mag_devices` ON `mag_devices`.`user_id` = `lines`.`id` LIMIT ' . $rStep . ', ' . $rMax . ';'); + } else { + $db->query('SELECT `id`, `username`, `password`, `exp_date`, `created_at`, `admin_enabled`, `enabled`, `bouquet`, `allowed_outputs`, `max_connections`, `is_trial`, `is_restreamer`, `is_stalker`, `is_mag`, `is_e2`, `is_isplock`, `allowed_ips`, `allowed_ua`, `pair_id`, `force_server_id`, `isp_desc`, `forced_country`, `bypass_ua`, `last_expiration_video`, `access_token`, `mag_devices`.`token` AS `mag_token`, `admin_notes`, `reseller_notes` FROM `lines` LEFT JOIN `mag_devices` ON `mag_devices`.`user_id` = `lines`.`id` WHERE `id` IN (' . implode(',', $cacheLockMechanism) . ');'); + } + if ($db->result) { + if ($db->result->rowCount() > 0) { + foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rUserInfo) { + $rExists[] = $rUserInfo['id']; + file_put_contents(LINES_TMP_PATH . 'line_i_' . $rUserInfo['id'], igbinary_serialize($rUserInfo)); + $rKey = (SettingsManager::get('case_sensitive_line') ? $rUserInfo['username'] . '_' . $rUserInfo['password'] : strtolower($rUserInfo['username'] . '_' . $rUserInfo['password'])); + file_put_contents(LINES_TMP_PATH . 'line_c_' . $rKey, $rUserInfo['id']); + if (!empty($rUserInfo['access_token'])) { + file_put_contents(LINES_TMP_PATH . 'line_t_' . $rUserInfo['access_token'], $rUserInfo['id']); + } + } + } + $db->result = null; + } + } + if (count($cacheLockMechanism) > 0) { + foreach ($cacheLockMechanism as $rForceID) { + if (!in_array($rForceID, $rExists) && file_exists(LINES_TMP_PATH . 'line_i_' . $rForceID)) { + unlink(LINES_TMP_PATH . 'line_i_' . $rForceID); + } + } + } + } + } - private function generateLinesPerIP(): void { - global $db; - $rLinesPerIP = [3600 => [], 86400 => [], 604800 => [], 0 => []]; - foreach (array_keys($rLinesPerIP) as $rTime) { - if ($rTime > 0) { - $db->query('SELECT `lines_activity`.`user_id`, COUNT(DISTINCT(`lines_activity`.`user_ip`)) AS `ip_count`, `lines`.`username` FROM `lines_activity` LEFT JOIN `lines` ON `lines`.`id` = `lines_activity`.`user_id` WHERE `date_start` >= ? AND `lines`.`is_mag` = 0 AND `lines`.`is_e2` = 0 AND `lines`.`is_restreamer` = 0 GROUP BY `lines_activity`.`user_id` ORDER BY `ip_count` DESC LIMIT 1000;', time() - $rTime); - } else { - $db->query('SELECT `lines_activity`.`user_id`, COUNT(DISTINCT(`lines_activity`.`user_ip`)) AS `ip_count`, `lines`.`username` FROM `lines_activity` LEFT JOIN `lines` ON `lines`.`id` = `lines_activity`.`user_id` WHERE `lines`.`is_mag` = 0 AND `lines`.`is_e2` = 0 AND `lines`.`is_restreamer` = 0 GROUP BY `lines_activity`.`user_id` ORDER BY `ip_count` DESC LIMIT 1000;'); - } - foreach ($db->get_rows() as $rRow) { - $rLinesPerIP[$rTime][] = $rRow; - } - } - file_put_contents(CACHE_TMP_PATH . 'lines_per_ip', igbinary_serialize($rLinesPerIP)); - } + private function generateStreams($rStart = null, $rCount = null, $cacheLockMechanism = []): void { + global $db; + if (is_null($rCount)) { + $rCount = count($cacheLockMechanism); + } + if ($rCount > 0) { + $rBouquetMap = []; + $rBouquetMapPath = CACHE_TMP_PATH . 'bouquet_map'; + if (file_exists($rBouquetMapPath) && 0 < filesize($rBouquetMapPath)) { + $rBouquetData = @igbinary_unserialize(file_get_contents($rBouquetMapPath)); + if (is_array($rBouquetData)) { + $rBouquetMap = $rBouquetData; + } + } + $rSteps = []; + if (!is_null($rStart)) { + $rEnd = $rStart + $rCount - 1; + if ($this->rSplit >= ($rEnd - $rStart + 1)) { + $rSteps = [$rStart]; + } + } else { + $rSteps = [null]; + } + $rExists = []; + foreach ($rSteps as $rStep) { + if (!is_null($rStep)) { + if ($rStart + $rCount < $rStep + $this->rSplit) { + $rMax = ($rStart + $rCount) - $rStep; + } else { + $rMax = $this->rSplit; + } + $db->query('SELECT t1.id,t1.epg_id,t1.added,t1.allow_record,t1.year,t1.channel_id,t1.movie_properties,t1.stream_source,t1.tv_archive_server_id,t1.vframes_server_id,t1.tv_archive_duration,t1.stream_icon,t1.custom_sid,t1.category_id,t1.stream_display_name,t1.series_no,t1.direct_source,t1.direct_proxy,t2.type_output,t1.target_container,t2.live,t1.rtmp_output,t1.order,t2.type_key,t1.tmdb_id,t1.adaptive_link FROM `streams` t1 INNER JOIN `streams_types` t2 ON t2.type_id = t1.type LIMIT ' . $rStep . ', ' . $rMax . ';'); + } else { + $db->query('SELECT t1.id,t1.epg_id,t1.added,t1.allow_record,t1.year,t1.channel_id,t1.movie_properties,t1.stream_source,t1.tv_archive_server_id,t1.vframes_server_id,t1.tv_archive_duration,t1.stream_icon,t1.custom_sid,t1.category_id,t1.stream_display_name,t1.series_no,t1.direct_source,t1.direct_proxy,t2.type_output,t1.target_container,t2.live,t1.rtmp_output,t1.order,t2.type_key,t1.tmdb_id,t1.adaptive_link FROM `streams` t1 INNER JOIN `streams_types` t2 ON t2.type_id = t1.type WHERE `t1`.`id` IN (' . implode(',', $cacheLockMechanism) . ');'); + } + if ($db->result) { + if ($db->result->rowCount() > 0) { + $rRows = $db->result->fetchAll(\PDO::FETCH_ASSOC); + $rStreamMap = $rStreamIDs = []; + foreach ($rRows as $rRow) { + $rStreamIDs[] = $rRow['id']; + } + if (count($rStreamIDs) > 0) { + if ($db->query('SELECT `stream_id`, `server_id`, `pid`, `to_analyze`, `stream_status`, `monitor_pid`, `on_demand`, `delay_available_at`, `bitrate`, `parent_id`, `on_demand`, `stream_info`, `video_codec`, `audio_codec`, `resolution`, `compatible` FROM `streams_servers` WHERE `stream_id` IN (' . implode(',', $rStreamIDs) . ')')) { + if ($db->result->rowCount() > 0) { + foreach ($db->result->fetchAll(\PDO::FETCH_ASSOC) as $rRow) { + $rStreamMap[intval($rRow['stream_id'])][intval($rRow['server_id'])] = $rRow; + } + } + $db->result = null; + } + } + foreach ($rRows as $rStreamInfo) { + $rExists[] = $rStreamInfo['id']; + if (!$rStreamInfo['direct_source']) { + unset($rStreamInfo['stream_source']); + } + $rOutput = ['info' => $rStreamInfo, 'bouquets' => ($rBouquetMap[intval($rStreamInfo['id'])] ?? []), 'servers' => ($rStreamMap[intval($rStreamInfo['id'])] ?? [])]; + file_put_contents(STREAMS_TMP_PATH . 'stream_' . $rStreamInfo['id'], igbinary_serialize($rOutput)); + } + unset($rRows, $rStreamMap, $rStreamIDs); + } + $db->result = null; + } + } + if (count($cacheLockMechanism) > 0) { + foreach ($cacheLockMechanism as $rForceID) { + if (!in_array($rForceID, $rExists) && file_exists(STREAMS_TMP_PATH . 'stream_' . $rForceID)) { + unlink(STREAMS_TMP_PATH . 'stream_' . $rForceID); + } + } + } + } + } - private function generateTheftDetection(): void { - global $db; - $rTheftDetection = [3600 => [], 86400 => [], 604800 => [], 0 => []]; - foreach (array_keys($rTheftDetection) as $rTime) { - if ($rTime > 0) { - $db->query('SELECT `lines_activity`.`user_id`, COUNT(DISTINCT(`lines_activity`.`stream_id`)) AS `vod_count`, `lines`.`username` FROM `lines_activity` LEFT JOIN `lines` ON `lines`.`id` = `lines_activity`.`user_id` WHERE `date_start` >= ? AND `lines`.`is_mag` = 0 AND `lines`.`is_e2` = 0 AND `lines`.`is_restreamer` = 0 AND `stream_id` IN (SELECT `id` FROM `streams` WHERE `type` IN (2,5)) GROUP BY `lines_activity`.`user_id` ORDER BY `vod_count` DESC LIMIT 1000;', time() - $rTime); - } else { - $db->query('SELECT `lines_activity`.`user_id`, COUNT(DISTINCT(`lines_activity`.`stream_id`)) AS `vod_count`, `lines`.`username` FROM `lines_activity` LEFT JOIN `lines` ON `lines`.`id` = `lines_activity`.`user_id` WHERE `lines`.`is_mag` = 0 AND `lines`.`is_e2` = 0 AND `lines`.`is_restreamer` = 0 AND `stream_id` IN (SELECT `id` FROM `streams` WHERE `type` IN (2,5)) GROUP BY `lines_activity`.`user_id` ORDER BY `vod_count` DESC LIMIT 1000;'); - } - foreach ($db->get_rows() as $rRow) { - $rTheftDetection[$rTime][] = $rRow; - } - } - file_put_contents(CACHE_TMP_PATH . 'theft_detection', igbinary_serialize($rTheftDetection)); - } + private function generateSeries($rStart, $rCount): void { + global $db; + $rSeriesMap = []; + $rSeriesEpisodes = []; + if ($rCount > 0) { + if (is_null($rStart)) { + $rSteps = [null]; + } else { + $rEnd = $rStart + $rCount - 1; + $rangeLength = $rEnd - $rStart + 1; + if ($this->rSplit >= $rangeLength) { + $rSteps = [$rStart]; + } else { + $rSteps = range($rStart, $rEnd, $this->rSplit); + } + } + foreach ($rSteps as $rStep) { + if ($rStart + $rCount < $rStep + $this->rSplit) { + $rMax = ($rStart + $rCount) - $rStep; + } else { + $rMax = $this->rSplit; + } + $db->query('SELECT `stream_id`, `series_id`, `season_num`, `episode_num` FROM `streams_episodes` WHERE `stream_id` IN (SELECT `id` FROM `streams` WHERE `type` = 5) ORDER BY `series_id` ASC, `season_num` ASC, `episode_num` ASC LIMIT ' . $rStep . ', ' . $rMax . ';'); + foreach ($db->get_rows() as $rRow) { + if ($rRow['stream_id'] && $rRow['series_id']) { + $rSeriesMap[intval($rRow['stream_id'])] = intval($rRow['series_id']); + if (!isset($rSeriesEpisodes[$rRow['series_id']])) { + $rSeriesEpisodes[$rRow['series_id']] = []; + } + $rSeriesEpisodes[$rRow['series_id']][$rRow['season_num']][] = ['episode_num' => $rRow['episode_num'], 'stream_id' => $rRow['stream_id']]; + } + } + } + } + file_put_contents(SERIES_TMP_PATH . 'series_episodes_' . $rStart, igbinary_serialize($rSeriesEpisodes)); + file_put_contents(SERIES_TMP_PATH . 'series_map_' . $rStart, igbinary_serialize($rSeriesMap)); + unset($rSeriesMap); + } - public function shutdown(): void { - global $db; - if (is_object($db)) { - $db->close_mysql(); - } - } + private function generateGroups(): void { + global $db; + $db->query('SELECT `group_id` FROM `users_groups`;'); + foreach ($db->get_rows() as $rGroup) { + $rBouquets = $rReturn = []; + $db->query("SELECT * FROM `users_packages` WHERE JSON_CONTAINS(`groups`, ?, '\$');", $rGroup['group_id']); + foreach ($db->get_rows() as $rRow) { + foreach (json_decode($rRow['bouquets'], true) as $rID) { + if (!in_array($rID, $rBouquets)) { + $rBouquets[] = $rID; + } + } + if ($rRow['is_line']) { + $rReturn['create_line'] = true; + } + if ($rRow['is_mag']) { + $rReturn['create_mag'] = true; + } + if ($rRow['is_e2']) { + $rReturn['create_enigma'] = true; + } + } + if (count($rBouquets) > 0) { + $db->query('SELECT * FROM `bouquets` WHERE `id` IN (' . implode(',', array_map('intval', $rBouquets)) . ');'); + $rSeriesIDs = []; + $rStreamIDs = []; + foreach ($db->get_rows() as $rRow) { + if ($rRow['bouquet_channels']) { + $rStreamIDs = array_merge($rStreamIDs, json_decode($rRow['bouquet_channels'], true)); + } + if ($rRow['bouquet_movies']) { + $rStreamIDs = array_merge($rStreamIDs, json_decode($rRow['bouquet_movies'], true)); + } + if ($rRow['bouquet_radios']) { + $rStreamIDs = array_merge($rStreamIDs, json_decode($rRow['bouquet_radios'], true)); + } + foreach (json_decode($rRow['bouquet_series'], true) as $rSeriesID) { + $rSeriesIDs[] = $rSeriesID; + $db->query('SELECT `stream_id` FROM `streams_episodes` WHERE `series_id` = ?;', $rSeriesID); + foreach ($db->get_rows() as $rEpisode) { + $rStreamIDs[] = $rEpisode['stream_id']; + } + } + } + $rReturn['stream_ids'] = array_unique($rStreamIDs); + $rReturn['series_ids'] = array_unique($rSeriesIDs); + $rCategories = []; + if (count($rReturn['stream_ids']) > 0) { + $db->query('SELECT DISTINCT(`category_id`) AS `category_id` FROM `streams` WHERE `id` IN (' . implode(',', array_map('intval', $rReturn['stream_ids'])) . ');'); + foreach ($db->get_rows() as $rRow) { + if ($rRow['category_id']) { + $rCategories = array_merge($rCategories, json_decode($rRow['category_id'], true)); + } + } + } + if (count($rReturn['series_ids']) > 0) { + $db->query('SELECT DISTINCT(`category_id`) AS `category_id` FROM `streams_series` WHERE `id` IN (' . implode(',', array_map('intval', $rReturn['series_ids'])) . ');'); + foreach ($db->get_rows() as $rRow) { + if ($rRow['category_id']) { + $rCategories = array_merge($rCategories, json_decode($rRow['category_id'], true)); + } + } + } + $rReturn['category_ids'] = array_unique($rCategories); + } + file_put_contents(CACHE_TMP_PATH . 'permissions_' . intval($rGroup['group_id']), igbinary_serialize($rReturn)); + } + } + + private function generateLinesPerIP(): void { + global $db; + $rLinesPerIP = [3600 => [], 86400 => [], 604800 => [], 0 => []]; + foreach (array_keys($rLinesPerIP) as $rTime) { + if ($rTime > 0) { + $db->query('SELECT `lines_activity`.`user_id`, COUNT(DISTINCT(`lines_activity`.`user_ip`)) AS `ip_count`, `lines`.`username` FROM `lines_activity` LEFT JOIN `lines` ON `lines`.`id` = `lines_activity`.`user_id` WHERE `date_start` >= ? AND `lines`.`is_mag` = 0 AND `lines`.`is_e2` = 0 AND `lines`.`is_restreamer` = 0 GROUP BY `lines_activity`.`user_id` ORDER BY `ip_count` DESC LIMIT 1000;', time() - $rTime); + } else { + $db->query('SELECT `lines_activity`.`user_id`, COUNT(DISTINCT(`lines_activity`.`user_ip`)) AS `ip_count`, `lines`.`username` FROM `lines_activity` LEFT JOIN `lines` ON `lines`.`id` = `lines_activity`.`user_id` WHERE `lines`.`is_mag` = 0 AND `lines`.`is_e2` = 0 AND `lines`.`is_restreamer` = 0 GROUP BY `lines_activity`.`user_id` ORDER BY `ip_count` DESC LIMIT 1000;'); + } + foreach ($db->get_rows() as $rRow) { + $rLinesPerIP[$rTime][] = $rRow; + } + } + file_put_contents(CACHE_TMP_PATH . 'lines_per_ip', igbinary_serialize($rLinesPerIP)); + } + + private function generateTheftDetection(): void { + global $db; + $rTheftDetection = [3600 => [], 86400 => [], 604800 => [], 0 => []]; + foreach (array_keys($rTheftDetection) as $rTime) { + if ($rTime > 0) { + $db->query('SELECT `lines_activity`.`user_id`, COUNT(DISTINCT(`lines_activity`.`stream_id`)) AS `vod_count`, `lines`.`username` FROM `lines_activity` LEFT JOIN `lines` ON `lines`.`id` = `lines_activity`.`user_id` WHERE `date_start` >= ? AND `lines`.`is_mag` = 0 AND `lines`.`is_e2` = 0 AND `lines`.`is_restreamer` = 0 AND `stream_id` IN (SELECT `id` FROM `streams` WHERE `type` IN (2,5)) GROUP BY `lines_activity`.`user_id` ORDER BY `vod_count` DESC LIMIT 1000;', time() - $rTime); + } else { + $db->query('SELECT `lines_activity`.`user_id`, COUNT(DISTINCT(`lines_activity`.`stream_id`)) AS `vod_count`, `lines`.`username` FROM `lines_activity` LEFT JOIN `lines` ON `lines`.`id` = `lines_activity`.`user_id` WHERE `lines`.`is_mag` = 0 AND `lines`.`is_e2` = 0 AND `lines`.`is_restreamer` = 0 AND `stream_id` IN (SELECT `id` FROM `streams` WHERE `type` IN (2,5)) GROUP BY `lines_activity`.`user_id` ORDER BY `vod_count` DESC LIMIT 1000;'); + } + foreach ($db->get_rows() as $rRow) { + $rTheftDetection[$rTime][] = $rRow; + } + } + file_put_contents(CACHE_TMP_PATH . 'theft_detection', igbinary_serialize($rTheftDetection)); + } + + public function shutdown(): void { + global $db; + if (is_object($db)) { + $db->close_mysql(); + } + } } diff --git a/src/Cli/CronJobs/CertbotCronJob.php b/src/Cli/CronJobs/CertbotCronJob.php index bb4f79b4..0f345494 100644 --- a/src/Cli/CronJobs/CertbotCronJob.php +++ b/src/Cli/CronJobs/CertbotCronJob.php @@ -19,80 +19,80 @@ use XcVm\Infrastructure\Database\DatabaseAware; */ class CertbotCronJob implements CommandInterface { - use DatabaseAware; - use CronTrait; + use DatabaseAware; + use CronTrait; - public function getName(): string { - return 'cron:certbot'; - } + public function getName(): string { + return 'cron:certbot'; + } - public function getDescription(): string { - return 'Cron: check/renew SSL certificates via certbot'; - } + public function getDescription(): string { + return 'Cron: check/renew SSL certificates via certbot'; + } - public function execute(array $rArgs): int { - $this->registerShutdown(); - $rCheck = !empty($rArgs[0]); - $this->loadCron($rCheck); - return 0; - } + public function execute(array $rArgs): int { + $this->registerShutdown(); + $rCheck = !empty($rArgs[0]); + $this->loadCron($rCheck); + return 0; + } - private function loadCron(bool $rCheck): void { - $db = self::db(); - $rCertInfo = null; + private function loadCron(bool $rCheck): void { + $db = self::db(); + $rCertInfo = null; - if (!$rCheck) { - if (!PHP_ERRORS) { - DiagnosticsService::submitPanelLogs(); - } - $rCertInfo = DiagnosticsService::getCertificateInfo(); - if (ServerRepository::getAll()[SERVER_ID]['enable_https'] && $rCertInfo) { - if ($rCertInfo['expiration'] - time() < 604800) { - echo 'Certificate due for renewal.' . "\n"; - $rData = array('action' => 'certbot_generate', 'domain' => array()); - foreach (explode(',', ServerRepository::getAll()[SERVER_ID]['domain_name']) as $rDomain) { - if (!filter_var($rDomain, FILTER_VALIDATE_IP)) { - $rData['domain'][] = $rDomain; - } - } - if (count($rData['domain']) > 0) { - $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', SERVER_ID, time(), json_encode($rData)); - } - } else { - echo 'Certificate valid, not due for renewal.' . "\n"; - } - } - } + if (!$rCheck) { + if (!PHP_ERRORS) { + DiagnosticsService::submitPanelLogs(); + } + $rCertInfo = DiagnosticsService::getCertificateInfo(); + if (ServerRepository::getAll()[SERVER_ID]['enable_https'] && $rCertInfo) { + if ($rCertInfo['expiration'] - time() < 604800) { + echo 'Certificate due for renewal.' . "\n"; + $rData = ['action' => 'certbot_generate', 'domain' => []]; + foreach (explode(',', ServerRepository::getAll()[SERVER_ID]['domain_name']) as $rDomain) { + if (!filter_var($rDomain, FILTER_VALIDATE_IP)) { + $rData['domain'][] = $rDomain; + } + } + if (count($rData['domain']) > 0) { + $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', SERVER_ID, time(), json_encode($rData)); + } + } else { + echo 'Certificate valid, not due for renewal.' . "\n"; + } + } + } - $db->query('SELECT `certbot_ssl` FROM `servers` WHERE `id` = ?;', SERVER_ID); - $rDBCertInfo = json_decode($db->get_row()['certbot_ssl'], true); - $rLines = explode("\n", file_get_contents(MAIN_HOME . 'bin/nginx/conf/ssl.conf')); + $db->query('SELECT `certbot_ssl` FROM `servers` WHERE `id` = ?;', SERVER_ID); + $rDBCertInfo = json_decode($db->get_row()['certbot_ssl'], true); + $rLines = explode("\n", file_get_contents(MAIN_HOME . 'bin/nginx/conf/ssl.conf')); - foreach ($rLines as $rLine) { - if (explode(' ', $rLine)[0] == 'ssl_certificate') { - list($rCertificate) = explode(';', explode(' ', $rLine)[1]); - if ($rCertificate != 'server.crt') { - $rCertInfoFile = DiagnosticsService::getCertificateInfo($rCertificate); - if ($rCertInfoFile && ($rCertInfo === null || $rCertInfo['serial'] != $rCertInfoFile['serial'] || !ServerRepository::getAll()[SERVER_ID]['certbot_ssl'] || $rDBCertInfo['serial'] != $rCertInfoFile['serial'])) { - $db->query('UPDATE `servers` SET `certbot_ssl` = ? WHERE `id` = ?;', json_encode($rCertInfoFile), SERVER_ID); - echo 'Updated ssl configuration in database' . "\n"; - $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', SERVER_ID, time(), json_encode(array('action' => 'reload_nginx'))); - } - } else { - if (ServerRepository::getAll()[SERVER_ID]['certbot_ssl']) { - $rCertInfo = json_decode(ServerRepository::getAll()[SERVER_ID]['certbot_ssl'], true); - if (file_exists($rCertInfo['path'] . '/fullchain.pem')) { - $rCertificate = $rCertInfo['path'] . '/fullchain.pem'; - $rChain = $rCertInfo['path'] . '/chain.pem'; - $rPrivateKey = $rCertInfo['path'] . '/privkey.pem'; - $rSSLConfig = 'ssl_certificate ' . $rCertificate . ';' . "\n" . 'ssl_certificate_key ' . $rPrivateKey . ';' . "\n" . 'ssl_trusted_certificate ' . $rChain . ';' . "\n" . 'ssl_protocols TLSv1.2 TLSv1.3;' . "\n" . 'ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;' . "\n" . 'ssl_prefer_server_ciphers off;' . "\n" . 'ssl_ecdh_curve auto;' . "\n" . 'ssl_session_timeout 10m;' . "\n" . 'ssl_session_cache shared:MozSSL:10m;' . "\n" . 'ssl_session_tickets off;'; - file_put_contents(BIN_PATH . 'nginx/conf/ssl.conf', $rSSLConfig); - echo 'Fixed ssl configuration file' . "\n"; - $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', SERVER_ID, time(), json_encode(array('action' => 'reload_nginx'))); - } - } - } - } - } - } + foreach ($rLines as $rLine) { + if (explode(' ', $rLine)[0] == 'ssl_certificate') { + list($rCertificate) = explode(';', explode(' ', $rLine)[1]); + if ($rCertificate != 'server.crt') { + $rCertInfoFile = DiagnosticsService::getCertificateInfo($rCertificate); + if ($rCertInfoFile && ($rCertInfo === null || $rCertInfo['serial'] != $rCertInfoFile['serial'] || !ServerRepository::getAll()[SERVER_ID]['certbot_ssl'] || $rDBCertInfo['serial'] != $rCertInfoFile['serial'])) { + $db->query('UPDATE `servers` SET `certbot_ssl` = ? WHERE `id` = ?;', json_encode($rCertInfoFile), SERVER_ID); + echo 'Updated ssl configuration in database' . "\n"; + $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', SERVER_ID, time(), json_encode(['action' => 'reload_nginx'])); + } + } else { + if (ServerRepository::getAll()[SERVER_ID]['certbot_ssl']) { + $rCertInfo = json_decode(ServerRepository::getAll()[SERVER_ID]['certbot_ssl'], true); + if (file_exists($rCertInfo['path'] . '/fullchain.pem')) { + $rCertificate = $rCertInfo['path'] . '/fullchain.pem'; + $rChain = $rCertInfo['path'] . '/chain.pem'; + $rPrivateKey = $rCertInfo['path'] . '/privkey.pem'; + $rSSLConfig = 'ssl_certificate ' . $rCertificate . ';' . "\n" . 'ssl_certificate_key ' . $rPrivateKey . ';' . "\n" . 'ssl_trusted_certificate ' . $rChain . ';' . "\n" . 'ssl_protocols TLSv1.2 TLSv1.3;' . "\n" . 'ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;' . "\n" . 'ssl_prefer_server_ciphers off;' . "\n" . 'ssl_ecdh_curve auto;' . "\n" . 'ssl_session_timeout 10m;' . "\n" . 'ssl_session_cache shared:MozSSL:10m;' . "\n" . 'ssl_session_tickets off;'; + file_put_contents(BIN_PATH . 'nginx/conf/ssl.conf', $rSSLConfig); + echo 'Fixed ssl configuration file' . "\n"; + $db->query('INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', SERVER_ID, time(), json_encode(['action' => 'reload_nginx'])); + } + } + } + } + } + } } diff --git a/src/Cli/CronJobs/CleanupCronJob.php b/src/Cli/CronJobs/CleanupCronJob.php index 501333a5..121d2802 100644 --- a/src/Cli/CronJobs/CleanupCronJob.php +++ b/src/Cli/CronJobs/CleanupCronJob.php @@ -22,196 +22,196 @@ use XcVm\Streaming\Codec\FFprobeRunner; */ class CleanupCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:cleanup'; - } + public function getName(): string { + return 'cron:cleanup'; + } - public function getDescription(): string { - return 'Cron: cleanup streams, archives, VOD, rotate tables'; - } + public function getDescription(): string { + return 'Cron: cleanup streams, archives, VOD, rotate tables'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->initCron('XC_VM[Cleanup]'); + $this->initCron('XC_VM[Cleanup]'); - $rTimeout = 3600; - set_time_limit($rTimeout); - ini_set('max_execution_time', $rTimeout); + $rTimeout = 3600; + set_time_limit($rTimeout); + ini_set('max_execution_time', $rTimeout); - $this->loadCron(); + $this->loadCron(); - return 0; - } + return 0; + } - private function loadCron(): void { - global $db; + private function loadCron(): void { + global $db; - if (intval(SettingsManager::get('cleanup')) == 1) { - $rStreams = array(); - $db->query('SELECT `id` FROM `streams` LEFT JOIN `streams_servers` ON `streams_servers`.`stream_id` = `streams`.`id` WHERE `streams`.`type` IN (1,3,4) AND `streams_servers`.`server_id` = ?;', SERVER_ID); - foreach ($db->get_rows() as $rRow) { - $rStreams[] = intval($rRow['id']); - } - foreach (glob(STREAMS_PATH . '*') as $rFilename) { - $rID = intval(rtrim(explode('.', basename($rFilename))[0], '_')) . "\n"; - if (0 < $rID && !in_array($rID, $rStreams)) { - echo 'Deleting: ' . $rFilename . "\n"; - unlink($rFilename); - } - } - $rArchive = array(); - $db->query('SELECT `id`, `tv_archive_duration` FROM `streams` WHERE `type` = 1 AND `tv_archive_server_id` = ? AND `tv_archive_duration` > 0;', SERVER_ID); - foreach ($db->get_rows() as $rRow) { - $rArchive[intval($rRow['id'])] = $rRow['tv_archive_duration']; - } - date_default_timezone_set('UTC'); - foreach (glob(ARCHIVE_PATH . '*') as $rStreamID) { - $rID = intval(basename($rStreamID)); - if (0 < $rID && is_dir(ARCHIVE_PATH . $rID)) { - if (!isset($rArchive[$rID])) { - echo 'Deleting: ' . $rStreamID . "\n"; - exec('rm -rf ' . $rStreamID); - } else { - $rDuration = $rArchive[$rID]; - $rDeleteBefore = time() - $rDuration * 86400 + 3600; - foreach (glob(ARCHIVE_PATH . $rID . '/*') as $rArchiveFile) { - list($rDate, $rTime) = explode(':', explode('.', basename($rArchiveFile))[0]); - list($rHour, $rMinute) = explode('-', $rTime); - $rFileTime = strtotime($rDate . ' ' . $rHour . ':' . $rMinute . ':00'); - if ($rFileTime < $rDeleteBefore) { - echo 'Deleting: ' . $rArchiveFile . "\n"; - unlink($rArchiveFile); - } - } - } - } - } - $rCreated = array(); - $db->query('SELECT `id` FROM `streams` LEFT JOIN `streams_servers` ON `streams_servers`.`stream_id` = `streams`.`id` WHERE `streams`.`type` = 3 AND `streams_servers`.`server_id` = ?;', SERVER_ID); - foreach ($db->get_rows() as $rRow) { - $rCreated[] = intval($rRow['id']); - } - foreach (glob(CREATED_PATH . '*') as $rFilename) { - $rID = intval(rtrim(explode('.', basename($rFilename))[0], '_')) . "\n"; - if (0 < $rID && !in_array($rID, $rCreated)) { - echo 'Deleting: ' . $rFilename . "\n"; - unlink($rFilename); - } - } - } + if (intval(SettingsManager::get('cleanup')) == 1) { + $rStreams = []; + $db->query('SELECT `id` FROM `streams` LEFT JOIN `streams_servers` ON `streams_servers`.`stream_id` = `streams`.`id` WHERE `streams`.`type` IN (1,3,4) AND `streams_servers`.`server_id` = ?;', SERVER_ID); + foreach ($db->get_rows() as $rRow) { + $rStreams[] = intval($rRow['id']); + } + foreach (glob(STREAMS_PATH . '*') as $rFilename) { + $rID = intval(rtrim(explode('.', basename($rFilename))[0], '_')) . "\n"; + if (0 < $rID && !in_array($rID, $rStreams)) { + echo 'Deleting: ' . $rFilename . "\n"; + unlink($rFilename); + } + } + $rArchive = []; + $db->query('SELECT `id`, `tv_archive_duration` FROM `streams` WHERE `type` = 1 AND `tv_archive_server_id` = ? AND `tv_archive_duration` > 0;', SERVER_ID); + foreach ($db->get_rows() as $rRow) { + $rArchive[intval($rRow['id'])] = $rRow['tv_archive_duration']; + } + date_default_timezone_set('UTC'); + foreach (glob(ARCHIVE_PATH . '*') as $rStreamID) { + $rID = intval(basename($rStreamID)); + if (0 < $rID && is_dir(ARCHIVE_PATH . $rID)) { + if (!isset($rArchive[$rID])) { + echo 'Deleting: ' . $rStreamID . "\n"; + exec('rm -rf ' . $rStreamID); + } else { + $rDuration = $rArchive[$rID]; + $rDeleteBefore = time() - $rDuration * 86400 + 3600; + foreach (glob(ARCHIVE_PATH . $rID . '/*') as $rArchiveFile) { + list($rDate, $rTime) = explode(':', explode('.', basename($rArchiveFile))[0]); + list($rHour, $rMinute) = explode('-', $rTime); + $rFileTime = strtotime($rDate . ' ' . $rHour . ':' . $rMinute . ':00'); + if ($rFileTime < $rDeleteBefore) { + echo 'Deleting: ' . $rArchiveFile . "\n"; + unlink($rArchiveFile); + } + } + } + } + } + $rCreated = []; + $db->query('SELECT `id` FROM `streams` LEFT JOIN `streams_servers` ON `streams_servers`.`stream_id` = `streams`.`id` WHERE `streams`.`type` = 3 AND `streams_servers`.`server_id` = ?;', SERVER_ID); + foreach ($db->get_rows() as $rRow) { + $rCreated[] = intval($rRow['id']); + } + foreach (glob(CREATED_PATH . '*') as $rFilename) { + $rID = intval(rtrim(explode('.', basename($rFilename))[0], '_')) . "\n"; + if (0 < $rID && !in_array($rID, $rCreated)) { + echo 'Deleting: ' . $rFilename . "\n"; + unlink($rFilename); + } + } + } - if (intval(SettingsManager::get('check_vod')) == 1) { - $db->query('SELECT `server_stream_id`, `id`, `target_container`, `movie_properties`, `stream_status` FROM `streams` LEFT JOIN `streams_servers` ON `streams_servers`.`stream_id` = `streams`.`id` WHERE `server_id` = ? AND `type` IN (2,5) AND `streams`.`direct_source` = 0 AND `streams_servers`.`pid` > 0;', SERVER_ID); - if ($db->num_rows() > 0) { - $rRows = $db->get_rows(); - foreach ($rRows as $rRow) { - $rMoviePath = VOD_PATH . $rRow['id'] . '.' . $rRow['target_container']; - if ($rRow['stream_status'] == 0) { - if (!file_exists($rMoviePath)) { - echo 'BAD MOVIE' . "\n"; - $db->query('UPDATE `streams_servers` SET `stream_status` = 1 WHERE `server_stream_id` = ?', $rRow['server_stream_id']); - StreamProcess::updateStream($rRow['id']); - } - } elseif ($rRow['stream_status'] == 1) { - if (file_exists($rMoviePath) && ($rFFProbee = FFprobeRunner::probeStream($rMoviePath))) { - $rDuration = (isset($rFFProbee['duration']) ? $rFFProbee['duration'] : 0); - sscanf($rDuration, '%d:%d:%d', $rHours, $rMinutes, $rSeconds); - $rSeconds = (isset($rSeconds) ? $rHours * 3600 + $rMinutes * 60 + $rSeconds : $rHours * 60 + $rMinutes); - $rSize = filesize($rMoviePath); - $rBitrate = round(($rSize * 0.008) / $rSeconds); - $rMovieProperties = json_decode($rRow['movie_properties'], true); - if (!is_array($rMovieProperties)) { - $rMovieProperties = array(); - } - if (!(isset($rMovieProperties['duration_secs']) && $rSeconds == $rMovieProperties['duration_secs'])) { - $rMovieProperties['duration_secs'] = $rSeconds; - $rMovieProperties['duration'] = $rDuration; - } - if (!(isset($rMovieProperties['video']) && $rFFProbee['codecs']['video']['codec_name'] == $rMovieProperties['video'])) { - $rMovieProperties['video'] = $rFFProbee['codecs']['video']; - } - if (!(isset($rMovieProperties['audio']) && $rFFProbee['codecs']['audio']['codec_name'] == $rMovieProperties['audio'])) { - $rMovieProperties['audio'] = $rFFProbee['codecs']['audio']; - } - if (SettingsManager::get('extract_subtitles')) { - if (!(isset($rMovieProperties['subtitle']) && $rFFProbee['codecs']['subtitle']['codec_name'] == $rMovieProperties['subtitle'])) { - $rMovieProperties['subtitle'] = $rFFProbee['codecs']['subtitle']; - } - } - if (!(isset($rMovieProperties['bitrate']) && $rBitrate == $rMovieProperties['bitrate'])) { - if (0 < $rBitrate) { - $rMovieProperties['bitrate'] = $rBitrate; - } else { - $rBitrate = $rMovieProperties['bitrate']; - } - } - if (isset($rFFProbee['codecs']['subtitle']) && SettingsManager::get('extract_subtitles')) { - $i = 0; - foreach ($rFFProbee['codecs']['subtitle'] as $rSubtitle) { - FFmpegCommand::extractSubtitle($rRow['stream_id'], $rMoviePath, $i); - $i++; - } - } - $rCompatible = intval(DiagnosticsService::checkCompatibility($rFFProbee, SettingsManager::get('player_allow_hevc'))); - $rAudioCodec = ($rFFProbee['codecs']['audio']['codec_name'] ?: null); - $rVideoCodec = ($rFFProbee['codecs']['video']['codec_name'] ?: null); - $rResolution = ($rFFProbee['codecs']['video']['height'] ?: null); - if ($rResolution) { - $rResolution = StreamSorter::getNearest(array(240, 360, 480, 576, 720, 1080, 1440, 2160), $rResolution); - } - $db->query('UPDATE `streams` SET `movie_properties` = ? WHERE `id` = ?', json_encode($rMovieProperties, JSON_UNESCAPED_UNICODE), $rRow['id']); - $db->query('UPDATE `streams_servers` SET `bitrate` = ?,`to_analyze` = 0,`stream_status` = 0,`stream_info` = ?, `audio_codec` = ?, `video_codec` = ?, `resolution` = ?, `compatible` = ? WHERE `server_stream_id` = ?', $rBitrate, json_encode($rFFProbee, JSON_UNESCAPED_UNICODE), $rAudioCodec, $rVideoCodec, $rResolution, $rCompatible, $rRow['server_stream_id']); - StreamProcess::updateStream($rRow['id']); - echo 'VALID MOVIE' . "\n"; - } - } - } - } - $db->query("SELECT `id`, `stream_display_name`, `server_stream_id` FROM `streams` t1 INNER JOIN `streams_servers` t3 ON t3.stream_id = t1.id LEFT JOIN `profiles` t2 ON t2.profile_id = t1.transcode_profile_id WHERE t1.type = 3 AND t3.server_id = ? AND JSON_CONTAINS(t3.cchannel_rsources, t1.stream_source) AND JSON_CONTAINS(t1.stream_source, t3.cchannel_rsources) AND t3.pids_create_channel = '[]';", SERVER_ID); - if ($db->num_rows() > 0) { - $rStreams = $db->get_rows(); - foreach ($rStreams as $rStream) { - echo "\n\n" . '[*] Checking Channel ' . $rStream['stream_display_name'] . "\n"; - if (file_exists(CREATED_PATH . $rStream['id'] . '_.list')) { - $rList = explode("\n", file_get_contents(CREATED_PATH . $rStream['id'] . '_.list')); - $rExisting = glob(CREATED_PATH . $rStream['id'] . '*.*'); - $rFailure = false; - $rActualFiles = array(); - foreach ($rList as $rItem) { - $rFilename = trim(explode("'", explode("'", $rItem)[1])[0]); - if (0 < strlen($rFilename)) { - if (in_array($rFilename, $rExisting)) { - $rActualFiles[] = $rFilename; - } else { - $rFailure = true; - } - } - } - if ($rFailure) { - echo 'BAD CHANNEL' . "\n"; - $db->query('UPDATE `streams_servers` SET `cchannel_rsources` = ? WHERE `server_stream_id` = ?;', json_encode($rActualFiles, JSON_UNESCAPED_UNICODE), $rStream['server_stream_id']); - StreamProcess::updateStream($rStream['id']); - } - } else { - echo 'BAD CHANNEL' . "\n"; - $db->query("UPDATE `streams_servers` SET `cchannel_rsources` = '[]' WHERE `server_stream_id` = ?;", $rStream['server_stream_id']); - StreamProcess::updateStream($rStream['id']); - } - } - } - } + if (intval(SettingsManager::get('check_vod')) == 1) { + $db->query('SELECT `server_stream_id`, `id`, `target_container`, `movie_properties`, `stream_status` FROM `streams` LEFT JOIN `streams_servers` ON `streams_servers`.`stream_id` = `streams`.`id` WHERE `server_id` = ? AND `type` IN (2,5) AND `streams`.`direct_source` = 0 AND `streams_servers`.`pid` > 0;', SERVER_ID); + if ($db->num_rows() > 0) { + $rRows = $db->get_rows(); + foreach ($rRows as $rRow) { + $rMoviePath = VOD_PATH . $rRow['id'] . '.' . $rRow['target_container']; + if ($rRow['stream_status'] == 0) { + if (!file_exists($rMoviePath)) { + echo 'BAD MOVIE' . "\n"; + $db->query('UPDATE `streams_servers` SET `stream_status` = 1 WHERE `server_stream_id` = ?', $rRow['server_stream_id']); + StreamProcess::updateStream($rRow['id']); + } + } elseif ($rRow['stream_status'] == 1) { + if (file_exists($rMoviePath) && ($rFFProbee = FFprobeRunner::probeStream($rMoviePath))) { + $rDuration = (isset($rFFProbee['duration']) ? $rFFProbee['duration'] : 0); + sscanf($rDuration, '%d:%d:%d', $rHours, $rMinutes, $rSeconds); + $rSeconds = (isset($rSeconds) ? $rHours * 3600 + $rMinutes * 60 + $rSeconds : $rHours * 60 + $rMinutes); + $rSize = filesize($rMoviePath); + $rBitrate = round(($rSize * 0.008) / $rSeconds); + $rMovieProperties = json_decode($rRow['movie_properties'], true); + if (!is_array($rMovieProperties)) { + $rMovieProperties = []; + } + if (!(isset($rMovieProperties['duration_secs']) && $rSeconds == $rMovieProperties['duration_secs'])) { + $rMovieProperties['duration_secs'] = $rSeconds; + $rMovieProperties['duration'] = $rDuration; + } + if (!(isset($rMovieProperties['video']) && $rFFProbee['codecs']['video']['codec_name'] == $rMovieProperties['video'])) { + $rMovieProperties['video'] = $rFFProbee['codecs']['video']; + } + if (!(isset($rMovieProperties['audio']) && $rFFProbee['codecs']['audio']['codec_name'] == $rMovieProperties['audio'])) { + $rMovieProperties['audio'] = $rFFProbee['codecs']['audio']; + } + if (SettingsManager::get('extract_subtitles')) { + if (!(isset($rMovieProperties['subtitle']) && $rFFProbee['codecs']['subtitle']['codec_name'] == $rMovieProperties['subtitle'])) { + $rMovieProperties['subtitle'] = $rFFProbee['codecs']['subtitle']; + } + } + if (!(isset($rMovieProperties['bitrate']) && $rBitrate == $rMovieProperties['bitrate'])) { + if (0 < $rBitrate) { + $rMovieProperties['bitrate'] = $rBitrate; + } else { + $rBitrate = $rMovieProperties['bitrate']; + } + } + if (isset($rFFProbee['codecs']['subtitle']) && SettingsManager::get('extract_subtitles')) { + $i = 0; + foreach ($rFFProbee['codecs']['subtitle'] as $rSubtitle) { + FFmpegCommand::extractSubtitle($rRow['stream_id'], $rMoviePath, $i); + $i++; + } + } + $rCompatible = intval(DiagnosticsService::checkCompatibility($rFFProbee, SettingsManager::get('player_allow_hevc'))); + $rAudioCodec = ($rFFProbee['codecs']['audio']['codec_name'] ?: null); + $rVideoCodec = ($rFFProbee['codecs']['video']['codec_name'] ?: null); + $rResolution = ($rFFProbee['codecs']['video']['height'] ?: null); + if ($rResolution) { + $rResolution = StreamSorter::getNearest([240, 360, 480, 576, 720, 1080, 1440, 2160], $rResolution); + } + $db->query('UPDATE `streams` SET `movie_properties` = ? WHERE `id` = ?', json_encode($rMovieProperties, JSON_UNESCAPED_UNICODE), $rRow['id']); + $db->query('UPDATE `streams_servers` SET `bitrate` = ?,`to_analyze` = 0,`stream_status` = 0,`stream_info` = ?, `audio_codec` = ?, `video_codec` = ?, `resolution` = ?, `compatible` = ? WHERE `server_stream_id` = ?', $rBitrate, json_encode($rFFProbee, JSON_UNESCAPED_UNICODE), $rAudioCodec, $rVideoCodec, $rResolution, $rCompatible, $rRow['server_stream_id']); + StreamProcess::updateStream($rRow['id']); + echo 'VALID MOVIE' . "\n"; + } + } + } + } + $db->query("SELECT `id`, `stream_display_name`, `server_stream_id` FROM `streams` t1 INNER JOIN `streams_servers` t3 ON t3.stream_id = t1.id LEFT JOIN `profiles` t2 ON t2.profile_id = t1.transcode_profile_id WHERE t1.type = 3 AND t3.server_id = ? AND JSON_CONTAINS(t3.cchannel_rsources, t1.stream_source) AND JSON_CONTAINS(t1.stream_source, t3.cchannel_rsources) AND t3.pids_create_channel = '[]';", SERVER_ID); + if ($db->num_rows() > 0) { + $rStreams = $db->get_rows(); + foreach ($rStreams as $rStream) { + echo "\n\n" . '[*] Checking Channel ' . $rStream['stream_display_name'] . "\n"; + if (file_exists(CREATED_PATH . $rStream['id'] . '_.list')) { + $rList = explode("\n", file_get_contents(CREATED_PATH . $rStream['id'] . '_.list')); + $rExisting = glob(CREATED_PATH . $rStream['id'] . '*.*'); + $rFailure = false; + $rActualFiles = []; + foreach ($rList as $rItem) { + $rFilename = trim(explode("'", explode("'", $rItem)[1])[0]); + if (0 < strlen($rFilename)) { + if (in_array($rFilename, $rExisting)) { + $rActualFiles[] = $rFilename; + } else { + $rFailure = true; + } + } + } + if ($rFailure) { + echo 'BAD CHANNEL' . "\n"; + $db->query('UPDATE `streams_servers` SET `cchannel_rsources` = ? WHERE `server_stream_id` = ?;', json_encode($rActualFiles, JSON_UNESCAPED_UNICODE), $rStream['server_stream_id']); + StreamProcess::updateStream($rStream['id']); + } + } else { + echo 'BAD CHANNEL' . "\n"; + $db->query("UPDATE `streams_servers` SET `cchannel_rsources` = '[]' WHERE `server_stream_id` = ?;", $rStream['server_stream_id']); + StreamProcess::updateStream($rStream['id']); + } + } + } + } - $rTables = array('lines_activity' => array('keep_activity', 'date_end'), 'lines_logs' => array('keep_client', 'date'), 'login_logs' => array('keep_login', 'date'), 'streams_errors' => array('keep_errors', 'date'), 'streams_logs' => array('keep_restarts', 'date'), 'ondemand_check' => array('on_demand_scan_keep', 'date')); - foreach ($rTables as $rTable => $rArray) { - if (SettingsManager::getAll()[$rArray[0]] && 0 < SettingsManager::getAll()[$rArray[0]]) { - $rDeleteBefore = time() - intval(SettingsManager::getAll()[$rArray[0]]); - $db->query('DELETE FROM `' . $rTable . '` WHERE `' . $rArray[1] . '` < ?;', $rDeleteBefore); - } - } - } + $rTables = ['lines_activity' => ['keep_activity', 'date_end'], 'lines_logs' => ['keep_client', 'date'], 'login_logs' => ['keep_login', 'date'], 'streams_errors' => ['keep_errors', 'date'], 'streams_logs' => ['keep_restarts', 'date'], 'ondemand_check' => ['on_demand_scan_keep', 'date']]; + foreach ($rTables as $rTable => $rArray) { + if (SettingsManager::getAll()[$rArray[0]] && 0 < SettingsManager::getAll()[$rArray[0]]) { + $rDeleteBefore = time() - intval(SettingsManager::getAll()[$rArray[0]]); + $db->query('DELETE FROM `' . $rTable . '` WHERE `' . $rArray[1] . '` < ?;', $rDeleteBefore); + } + } + } } diff --git a/src/Cli/CronJobs/EpgCronJob.php b/src/Cli/CronJobs/EpgCronJob.php index f6a430d1..d1262a80 100644 --- a/src/Cli/CronJobs/EpgCronJob.php +++ b/src/Cli/CronJobs/EpgCronJob.php @@ -19,348 +19,354 @@ use XcVm\Domain\Epg\EPG; */ class EpgCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:epg'; - } + public function getName(): string { + return 'cron:epg'; + } - public function getDescription(): string { - return 'Cron: import EPG data, generate XMLTV, per-stream cache'; - } + public function getDescription(): string { + return 'Cron: import EPG data, generate XMLTV, per-stream cache'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $rEPGID = null; - if (!empty($rArgs[0])) { - $rEPGID = intval($rArgs[0]); - } + $rEPGID = null; + if (!empty($rArgs[0])) { + $rEPGID = intval($rArgs[0]); + } - $this->printLog("=== XC_VM[EPG] Process started ==="); - $this->printLog("Mode: " . ($rEPGID ? "Single EPG ID: $rEPGID" : "Full update")); + $this->printLog("=== XC_VM[EPG] Process started ==="); + $this->printLog("Mode: " . ($rEPGID ? "Single EPG ID: $rEPGID" : "Full update")); - set_time_limit(0); - ini_set('memory_limit', -1); + set_time_limit(0); + ini_set('memory_limit', -1); - shell_exec('kill -9 `ps -ef | grep \'XC_VM\\[EPG\\]\' | grep -v grep | awk \'{print $2}\'`;'); - cli_set_process_title('XC_VM[EPG]'); + shell_exec('kill -9 `ps -ef | grep \'XC_VM\\[EPG\\]\' | grep -v grep | awk \'{print $2}\'`;'); + cli_set_process_title('XC_VM[EPG]'); - global $db; + global $db; - if (SettingsManager::get('force_epg_timezone')) { - date_default_timezone_set('UTC'); - $this->printLog("[SYSTEM] Forced timezone to UTC"); - } + if (SettingsManager::get('force_epg_timezone')) { + date_default_timezone_set('UTC'); + $this->printLog("[SYSTEM] Forced timezone to UTC"); + } - $this->printLog("[EPG] Clearing old channel mappings..."); - if ($rEPGID) { - $db->query('DELETE FROM `epg_channels` WHERE `epg_id` = ?;', $rEPGID); - $db->query('SELECT * FROM `epg` WHERE `id` = ?;', $rEPGID); - } else { - $db->query('TRUNCATE `epg_channels`;'); - $db->query('SELECT * FROM `epg`;'); - } + $this->printLog("[EPG] Clearing old channel mappings..."); + if ($rEPGID) { + $db->query('DELETE FROM `epg_channels` WHERE `epg_id` = ?;', $rEPGID); + $db->query('SELECT * FROM `epg` WHERE `id` = ?;', $rEPGID); + } else { + $db->query('TRUNCATE `epg_channels`;'); + $db->query('SELECT * FROM `epg`;'); + } - $epgSources = $db->get_rows(); - $this->printLog("[EPG] Found " . count($epgSources) . " EPG sources to process"); + $epgSources = $db->get_rows(); + $this->printLog("[EPG] Found " . count($epgSources) . " EPG sources to process"); - foreach ($epgSources as $rRow) { - $this->printLog("[EPG] Processing source ID: {$rRow['id']} | File: {$rRow['epg_file']}"); - $rEPG = new EPG($rRow['epg_file']); + foreach ($epgSources as $rRow) { + $this->printLog("[EPG] Processing source ID: {$rRow['id']} | File: {$rRow['epg_file']}"); + $rEPG = new EPG($rRow['epg_file']); - if ($rEPG->rValid) { - $rData = $rEPG->getData(); + if ($rEPG->rValid) { + $rData = $rEPG->getData(); - $this->reconnectDb(); + $this->reconnectDb(); - $db->query('UPDATE `epg` SET `data` = ?, `last_updated` = ? WHERE `id` = ?', json_encode($rData, JSON_UNESCAPED_UNICODE), time(), $rRow['id']); + $db->query('UPDATE `epg` SET `data` = ?, `last_updated` = ? WHERE `id` = ?', json_encode($rData, JSON_UNESCAPED_UNICODE), time(), $rRow['id']); - $this->printLog("[EPG] Updated metadata for EPG ID {$rRow['id']}, found " . count($rData) . " channels"); + $this->printLog("[EPG] Updated metadata for EPG ID {$rRow['id']}, found " . count($rData) . " channels"); - foreach ($rData as $rID => $rArray) { - $db->query('INSERT INTO `epg_channels`(`epg_id`, `channel_id`, `name`, `langs`) VALUES(?, ?, ?, ?);', $rRow['id'], $rID, $rArray['display_name'], json_encode($rArray['langs'])); - } - } else { - $this->printLog("[EPG] Failed to load EPG source ID {$rRow['id']}"); - } - } + foreach ($rData as $rID => $rArray) { + $db->query('INSERT INTO `epg_channels`(`epg_id`, `channel_id`, `name`, `langs`) VALUES(?, ?, ?, ?);', $rRow['id'], $rID, $rArray['display_name'], json_encode($rArray['langs'])); + } + } else { + $this->printLog("[EPG] Failed to load EPG source ID {$rRow['id']}"); + } + } - $this->printLog("[EPG] Starting full programme data import..."); + $this->printLog("[EPG] Starting full programme data import..."); - if ($rEPGID) { - $db->query('SELECT DISTINCT(t1.`epg_id`), t2.* FROM `streams` t1 INNER JOIN `epg` t2 ON t2.id = t1.epg_id WHERE t1.`epg_id` IS NOT NULL AND t2.id = ?;', $rEPGID); - } else { - $db->query('SELECT DISTINCT(t1.`epg_id`), t2.* FROM `streams` t1 INNER JOIN `epg` t2 ON t2.id = t1.epg_id WHERE t1.`epg_id` IS NOT NULL;'); - } + if ($rEPGID) { + $db->query('SELECT DISTINCT(t1.`epg_id`), t2.* FROM `streams` t1 INNER JOIN `epg` t2 ON t2.id = t1.epg_id WHERE t1.`epg_id` IS NOT NULL AND t2.id = ?;', $rEPGID); + } else { + $db->query('SELECT DISTINCT(t1.`epg_id`), t2.* FROM `streams` t1 INNER JOIN `epg` t2 ON t2.id = t1.epg_id WHERE t1.`epg_id` IS NOT NULL;'); + } - foreach ($db->get_rows() as $rData) { - $this->printLog("[EPG] === Processing EPG ID: {$rData['epg_id']} ==="); + foreach ($db->get_rows() as $rData) { + $this->printLog("[EPG] === Processing EPG ID: {$rData['epg_id']} ==="); - if ($rData['days_keep'] == 0) { - $this->printLog("[EPG] Clearing all existing data for EPG ID {$rData['epg_id']}"); - $db->query('DELETE FROM `epg_data` WHERE `epg_id` = ?', $rData['epg_id']); - } + if ($rData['days_keep'] == 0) { + $this->printLog("[EPG] Clearing all existing data for EPG ID {$rData['epg_id']}"); + $db->query('DELETE FROM `epg_data` WHERE `epg_id` = ?', $rData['epg_id']); + } - $rEPG = new EPG($rData['epg_file'], true); - if ($rEPG->rValid) { - $db->query('SELECT t1.`channel_id`, t1.`epg_lang`, t1.`epg_offset`, last_row.start + $rEPG = new EPG($rData['epg_file'], true); + if ($rEPG->rValid) { + $db->query('SELECT t1.`channel_id`, t1.`epg_lang`, t1.`epg_offset`, last_row.start FROM `streams` t1 LEFT JOIN (SELECT channel_id, MAX(`start`) as start FROM epg_data WHERE epg_id = ? GROUP BY channel_id) last_row ON last_row.channel_id = t1.channel_id WHERE `epg_id` = ?;', $rData['epg_id'], $rData['epg_id']); - $channelMap = $db->get_rows(true, 'channel_id'); + $channelMap = $db->get_rows(true, 'channel_id'); - $batches = $rEPG->parseEPG($rData['epg_id'], $channelMap, intval($rData['offset']) ?: 0); + $batches = $rEPG->parseEPG($rData['epg_id'], $channelMap, intval($rData['offset']) ?: 0); - $this->reconnectDb(); + $this->reconnectDb(); - if ($batches) { - $totalInserted = 0; - foreach ($batches as $insertBatch) { - if (!empty($insertBatch)) { - $db->simple_query('INSERT INTO `epg_data` (`epg_id`,`channel_id`,`start`,`end`,`lang`,`title`,`description`) VALUES ' . $insertBatch); - $totalInserted += substr_count($insertBatch, '),(') + 1; - } - } - $this->printLog("[EPG] Inserted $totalInserted programmes for EPG ID {$rData['epg_id']}"); - } else { - $this->printLog("[EPG] No new programmes found for EPG ID {$rData['epg_id']}"); - } + if ($batches) { + $totalInserted = 0; + foreach ($batches as $insertBatch) { + if (!empty($insertBatch)) { + $db->simple_query('INSERT INTO `epg_data` (`epg_id`,`channel_id`,`start`,`end`,`lang`,`title`,`description`) VALUES ' . $insertBatch); + $totalInserted += substr_count($insertBatch, '),(') + 1; + } + } + $this->printLog("[EPG] Inserted $totalInserted programmes for EPG ID {$rData['epg_id']}"); + } else { + $this->printLog("[EPG] No new programmes found for EPG ID {$rData['epg_id']}"); + } - $db->query('UPDATE `epg` SET `last_updated` = ? WHERE `id` = ?', time(), $rData['epg_id']); - } else { - $this->printLog("[EPG] Failed to parse EPG file for ID {$rData['epg_id']}"); - } + $db->query('UPDATE `epg` SET `last_updated` = ? WHERE `id` = ?', time(), $rData['epg_id']); + } else { + $this->printLog("[EPG] Failed to parse EPG file for ID {$rData['epg_id']}"); + } - if ($rData['days_keep'] > 0) { - $cleanupTime = strtotime('-' . (int)$rData['days_keep'] . ' days'); - if ($cleanupTime !== false) { - $db->query('DELETE FROM `epg_data` WHERE `epg_id` = ? AND `start` < ?', $rData['epg_id'], $cleanupTime); - echo "[EPG] Cleaned up old data (older than {$rData['days_keep']} days)\n"; - } else { - echo "[EPG] Invalid days_keep value, skipping cleanup\n"; - } - } - } + if ($rData['days_keep'] > 0) { + $cleanupTime = strtotime('-' . (int) $rData['days_keep'] . ' days'); + if ($cleanupTime !== false) { + $db->query('DELETE FROM `epg_data` WHERE `epg_id` = ? AND `start` < ?', $rData['epg_id'], $cleanupTime); + echo "[EPG] Cleaned up old data (older than {$rData['days_keep']} days)\n"; + } else { + echo "[EPG] Invalid days_keep value, skipping cleanup\n"; + } + } + } - $this->printLog("[EPG] Removing duplicate EPG entries..."); - $db->query('DELETE n1 FROM `epg_data` n1, `epg_data` n2 WHERE n1.id < n2.id AND n1.epg_id = n2.epg_id AND n1.channel_id = n2.channel_id AND n1.start = n2.start;'); + $this->printLog("[EPG] Removing duplicate EPG entries..."); + $db->query('DELETE n1 FROM `epg_data` n1, `epg_data` n2 WHERE n1.id < n2.id AND n1.epg_id = n2.epg_id AND n1.channel_id = n2.channel_id AND n1.start = n2.start;'); - $this->printLog("[EPG] Cleaning temporary XML files..."); - shell_exec('rm -f ' . TMP_PATH . '*.xml'); + $this->printLog("[EPG] Cleaning temporary XML files..."); + shell_exec('rm -f ' . TMP_PATH . '*.xml'); - // Marks the start of recording files. Everything that is (re)recorded in this - // execution will have mtime >= $runStart; cleanup at the end only removes what doesn't - // was played in this round (orphans of old executions). Without this, the - // old "time () - 10" erased the caches written at the beginning of the loop - // (which takes well over 10s), leaving most channels without EPG. - $runStart = time(); + // Marks the start of recording files. Everything that is (re)recorded in this + // execution will have mtime >= $runStart; cleanup at the end only removes what doesn't + // was played in this round (orphans of old executions). Without this, the + // old "time () - 10" erased the caches written at the beginning of the loop + // (which takes well over 10s), leaving most channels without EPG. + $runStart = time(); - $this->printLog("[XMLTV] Starting XMLTV generation..."); - $ApiDependencyIdentifier = $this->getBouquetGroups(); + $this->printLog("[XMLTV] Starting XMLTV generation..."); + $ApiDependencyIdentifier = $this->getBouquetGroups(); - $totalBouquets = count($ApiDependencyIdentifier); - $this->printLog("[XMLTV] Generating XMLTV for $totalBouquets bouquet(s)"); + $totalBouquets = count($ApiDependencyIdentifier); + $this->printLog("[XMLTV] Generating XMLTV for $totalBouquets bouquet(s)"); - foreach ($ApiDependencyIdentifier as $rBouquet => $BatchProcessId) { - if (!(strlen($rBouquet) > 0 && (count($BatchProcessId['streams']) > 0 || $rBouquet == 'all'))) { - continue; - } + foreach ($ApiDependencyIdentifier as $rBouquet => $BatchProcessId) { + if (!(strlen($rBouquet) > 0 && (count($BatchProcessId['streams']) > 0 || $rBouquet == 'all'))) { + continue; + } - $this->printLog("[XMLTV] Generating EPG for bouquet: " . ($rBouquet === 'all' ? 'ALL' : $rBouquet)); + $this->printLog("[XMLTV] Generating EPG for bouquet: " . ($rBouquet === 'all' ? 'ALL' : $rBouquet)); - $rOutput = ''; - $rServerName = htmlspecialchars(SettingsManager::get('server_name'), ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); - $rOutput .= '' . "\n"; - $rOutput .= '' . "\n"; + $rOutput = ''; + $rServerName = htmlspecialchars(SettingsManager::get('server_name'), ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); + $rOutput .= '' . "\n"; + $rOutput .= '' . "\n"; - if ($rBouquet == 'all') { - $db->query('SELECT `stream_display_name`,`stream_icon`,`channel_id`,`epg_id`,`tv_archive_duration` FROM `streams` WHERE `epg_id` IS NOT NULL AND `channel_id` IS NOT NULL;'); - } else { - $db->query('SELECT `stream_display_name`,`stream_icon`,`channel_id`,`epg_id`,`tv_archive_duration` FROM `streams` WHERE `epg_id` IS NOT NULL AND `channel_id` IS NOT NULL AND `id` IN (' . implode(',', array_map('intval', $BatchProcessId['streams'])) . ');'); - } + if ($rBouquet == 'all') { + $db->query('SELECT `stream_display_name`,`stream_icon`,`channel_id`,`epg_id`,`tv_archive_duration` FROM `streams` WHERE `epg_id` IS NOT NULL AND `channel_id` IS NOT NULL;'); + } else { + $db->query('SELECT `stream_display_name`,`stream_icon`,`channel_id`,`epg_id`,`tv_archive_duration` FROM `streams` WHERE `epg_id` IS NOT NULL AND `channel_id` IS NOT NULL AND `id` IN (' . implode(',', array_map('intval', $BatchProcessId['streams'])) . ');'); + } - $channels = $db->get_rows(); - $channelCount = count($channels); - $this->printLog("[XMLTV] Found $channelCount channels in this bouquet"); + $channels = $db->get_rows(); + $channelCount = count($channels); + $this->printLog("[XMLTV] Found $channelCount channels in this bouquet"); - $fa4629d757fa3640 = []; - $hasArchive = 0; + $fa4629d757fa3640 = []; + $hasArchive = 0; - foreach ($channels as $rRow) { - if ($rRow['tv_archive_duration'] > 0) $hasArchive++; + foreach ($channels as $rRow) { + if ($rRow['tv_archive_duration'] > 0) { + $hasArchive++; + } - $displayName = htmlspecialchars($rRow['stream_display_name'], ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); - $icon = htmlspecialchars(ImageUtils::validateURL($rRow['stream_icon']), ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); - $channelID = htmlspecialchars($rRow['channel_id'], ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); + $displayName = htmlspecialchars($rRow['stream_display_name'], ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); + $icon = htmlspecialchars(ImageUtils::validateURL($rRow['stream_icon']), ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); + $channelID = htmlspecialchars($rRow['channel_id'], ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); - $rOutput .= "\t"; - $rOutput .= "\t\t$displayName"; - if (!empty($rRow['stream_icon'])) { - $rOutput .= "\t\t"; - } - $rOutput .= "\t"; + $rOutput .= "\t"; + $rOutput .= "\t\t$displayName"; + if (!empty($rRow['stream_icon'])) { + $rOutput .= "\t\t"; + } + $rOutput .= "\t"; - $fa4629d757fa3640[] = $rRow['epg_id']; - } + $fa4629d757fa3640[] = $rRow['epg_id']; + } - $fa4629d757fa3640 = array_unique($fa4629d757fa3640); + $fa4629d757fa3640 = array_unique($fa4629d757fa3640); - if (count($fa4629d757fa3640) > 0) { - if ($hasArchive > 0) { - $this->printLog("[XMLTV] Archive channels detected ($hasArchive), including all historical programmes"); - $db->query('SELECT * FROM `epg_data` WHERE `epg_id` IN (' . implode(',', array_map('intval', $fa4629d757fa3640)) . ');'); - } else { - $this->printLog("[XMLTV] No archive channels, filtering only current/future programmes"); - $db->query('SELECT * FROM `epg_data` WHERE `epg_id` IN (' . implode(',', array_map('intval', $fa4629d757fa3640)) . ') AND `end` >= UNIX_TIMESTAMP();'); - } + if (count($fa4629d757fa3640) > 0) { + if ($hasArchive > 0) { + $this->printLog("[XMLTV] Archive channels detected ($hasArchive), including all historical programmes"); + $db->query('SELECT * FROM `epg_data` WHERE `epg_id` IN (' . implode(',', array_map('intval', $fa4629d757fa3640)) . ');'); + } else { + $this->printLog("[XMLTV] No archive channels, filtering only current/future programmes"); + $db->query('SELECT * FROM `epg_data` WHERE `epg_id` IN (' . implode(',', array_map('intval', $fa4629d757fa3640)) . ') AND `end` >= UNIX_TIMESTAMP();'); + } - $programmes = $db->get_rows(); - $progCount = count($programmes); - $this->printLog("[XMLTV] Adding $progCount programmes to XML"); + $programmes = $db->get_rows(); + $progCount = count($programmes); + $this->printLog("[XMLTV] Adding $progCount programmes to XML"); - $seen = []; - foreach ($programmes as $rRow) { - $key = $rRow['channel_id'] . '|' . $rRow['start']; - if (isset($seen[$key])) continue; - $seen[$key] = true; + $seen = []; + foreach ($programmes as $rRow) { + $key = $rRow['channel_id'] . '|' . $rRow['start']; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; - $rTitle = htmlspecialchars($rRow['title'] ?? '', ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); - $rDescription = htmlspecialchars($rRow['description'] ?? '', ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); - $rChannelID = htmlspecialchars($rRow['channel_id'], ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); - $rStart = date('YmdHis', $rRow['start']) . ' ' . str_replace(':', '', date('P', $rRow['start'])); - $rEnd = date('YmdHis', $rRow['end']) . ' ' . str_replace(':', '', date('P', $rRow['end'])); + $rTitle = htmlspecialchars($rRow['title'] ?? '', ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); + $rDescription = htmlspecialchars($rRow['description'] ?? '', ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); + $rChannelID = htmlspecialchars($rRow['channel_id'], ENT_XML1 | ENT_QUOTES | ENT_DISALLOWED, 'UTF-8'); + $rStart = date('YmdHis', $rRow['start']) . ' ' . str_replace(':', '', date('P', $rRow['start'])); + $rEnd = date('YmdHis', $rRow['end']) . ' ' . str_replace(':', '', date('P', $rRow['end'])); - $rOutput .= "\t"; - $rOutput .= "\t\t$rTitle"; - $rOutput .= "\t\t$rDescription"; - $rOutput .= "\t"; - } - } + $rOutput .= "\t"; + $rOutput .= "\t\t$rTitle"; + $rOutput .= "\t\t$rDescription"; + $rOutput .= "\t"; + } + } - $rOutput .= ''; - $fileName = ($rBouquet == 'all' ? 'all' : md5($rBouquet)); - $xmlPath = EPG_PATH . 'epg_' . $fileName . '.xml'; - $gzPath = EPG_PATH . 'epg_' . $fileName . '.xml.gz'; + $rOutput .= ''; + $fileName = ($rBouquet == 'all' ? 'all' : md5($rBouquet)); + $xmlPath = EPG_PATH . 'epg_' . $fileName . '.xml'; + $gzPath = EPG_PATH . 'epg_' . $fileName . '.xml.gz'; - file_put_contents($xmlPath, $rOutput); - $gz = gzopen($gzPath, 'w9'); - gzwrite($gz, $rOutput); - gzclose($gz); + file_put_contents($xmlPath, $rOutput); + $gz = gzopen($gzPath, 'w9'); + gzwrite($gz, $rOutput); + gzclose($gz); - $this->printLog("[XMLTV] Saved epg_$fileName.xml.gz (" . number_format(strlen($rOutput)) . " bytes)"); - } + $this->printLog("[XMLTV] Saved epg_$fileName.xml.gz (" . number_format(strlen($rOutput)) . " bytes)"); + } - $this->printLog("[CACHE] Building per-stream EPG cache..."); - $db->query('SELECT `id`, `epg_id`, `channel_id` FROM `streams` WHERE `type` = 1 AND `epg_id` IS NOT NULL AND `channel_id` IS NOT NULL;'); - $streams = $db->get_rows(); - $this->printLog("[CACHE] Caching EPG for " . count($streams) . " live streams"); + $this->printLog("[CACHE] Building per-stream EPG cache..."); + $db->query('SELECT `id`, `epg_id`, `channel_id` FROM `streams` WHERE `type` = 1 AND `epg_id` IS NOT NULL AND `channel_id` IS NOT NULL;'); + $streams = $db->get_rows(); + $this->printLog("[CACHE] Caching EPG for " . count($streams) . " live streams"); - foreach ($streams as $rRow) { - $rEPGData = []; - $seen = []; + foreach ($streams as $rRow) { + $rEPGData = []; + $seen = []; - $db->query('SELECT * FROM `epg_data` WHERE `epg_id` = ? AND `channel_id` = ? ORDER BY `start` ASC;', $rRow['epg_id'], $rRow['channel_id']); - foreach ($db->get_rows() as $prog) { - if (!in_array($prog['start'], $seen)) { - $seen[] = $prog['start']; - $rEPGData[] = $prog; - } - } + $db->query('SELECT * FROM `epg_data` WHERE `epg_id` = ? AND `channel_id` = ? ORDER BY `start` ASC;', $rRow['epg_id'], $rRow['channel_id']); + foreach ($db->get_rows() as $prog) { + if (!in_array($prog['start'], $seen)) { + $seen[] = $prog['start']; + $rEPGData[] = $prog; + } + } - if (count($rEPGData) > 0) { - file_put_contents(EPG_PATH . 'stream_' . $rRow['id'], igbinary_serialize($rEPGData)); - } - } + if (count($rEPGData) > 0) { + file_put_contents(EPG_PATH . 'stream_' . $rRow['id'], igbinary_serialize($rEPGData)); + } + } - $this->printLog("[CLEANUP] Removing orphan cache files..."); - $deleted = 0; - clearstatcache(); - foreach (scandir(EPG_PATH) as $rFile) { - if ($rFile === '.' || $rFile === '..') continue; - $fullPath = EPG_PATH . $rFile; - // Only removes files that were not rewritten in this run - // (mtime before the start of the Round = old execution orphan). - if (filemtime($fullPath) < $runStart) { - unlink($fullPath); - $deleted++; - } - } - $this->printLog("[CLEANUP] Deleted $deleted orphan cache files"); + $this->printLog("[CLEANUP] Removing orphan cache files..."); + $deleted = 0; + clearstatcache(); + foreach (scandir(EPG_PATH) as $rFile) { + if ($rFile === '.' || $rFile === '..') { + continue; + } + $fullPath = EPG_PATH . $rFile; + // Only removes files that were not rewritten in this run + // (mtime before the start of the Round = old execution orphan). + if (filemtime($fullPath) < $runStart) { + unlink($fullPath); + $deleted++; + } + } + $this->printLog("[CLEANUP] Deleted $deleted orphan cache files"); - $this->printLog("=== EPG processing completed successfully! ==="); + $this->printLog("=== EPG processing completed successfully! ==="); - return 0; - } + return 0; + } - private function printLog(string $message): void { - echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n"; - } + private function printLog(string $message): void { + echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n"; + } - private function reconnectDb(): void { - global $db; - if ($db->ping()) { - $this->printLog("[EPG] Database connection is alive."); - } else { - $this->printLog("[EPG] Database connection lost. Attempting to reconnect..."); - $db->db_connect(); - if ($db->ping()) { - $this->printLog("[EPG] Reconnected to the database successfully."); - } else { - $this->printLog("[EPG] Failed to reconnect to the database. Exiting."); - exit(1); - } - } - } + private function reconnectDb(): void { + global $db; + if ($db->ping()) { + $this->printLog("[EPG] Database connection is alive."); + } else { + $this->printLog("[EPG] Database connection lost. Attempting to reconnect..."); + $db->db_connect(); + if ($db->ping()) { + $this->printLog("[EPG] Reconnected to the database successfully."); + } else { + $this->printLog("[EPG] Failed to reconnect to the database. Exiting."); + exit(1); + } + } + } - private function getBouquetGroups(): array { - global $db; - $this->printLog("[XMLTV] Building bouquet groups..."); - $db->query('SELECT DISTINCT(`bouquet`) AS `bouquet` FROM `lines`;'); - $ApiDependencyIdentifier = [ - 'all' => [ - 'streams' => [], - 'bouquets' => [] - ] - ]; + private function getBouquetGroups(): array { + global $db; + $this->printLog("[XMLTV] Building bouquet groups..."); + $db->query('SELECT DISTINCT(`bouquet`) AS `bouquet` FROM `lines`;'); + $ApiDependencyIdentifier = [ + 'all' => [ + 'streams' => [], + 'bouquets' => [] + ] + ]; - foreach ($db->get_rows() as $rRow) { - $rBouquets = json_decode($rRow['bouquet'] ?? null, true); + foreach ($db->get_rows() as $rRow) { + $rBouquets = json_decode($rRow['bouquet'] ?? null, true); - if (!is_array($rBouquets) || empty($rBouquets)) { - $this->printLog("[XMLTV] Skipping invalid/empty bouquet value: " . var_export($rBouquets, true)); - continue; - } + if (!is_array($rBouquets) || empty($rBouquets)) { + $this->printLog("[XMLTV] Skipping invalid/empty bouquet value: " . var_export($rBouquets, true)); + continue; + } - sort($rBouquets); - $ApiDependencyIdentifier[implode('_', $rBouquets)] = [ - 'streams' => [], - 'bouquets' => $rBouquets - ]; - } - $count = count($ApiDependencyIdentifier); - $this->printLog("[XMLTV] Found $count bouquet groups (including 'all')"); + sort($rBouquets); + $ApiDependencyIdentifier[implode('_', $rBouquets)] = [ + 'streams' => [], + 'bouquets' => $rBouquets + ]; + } + $count = count($ApiDependencyIdentifier); + $this->printLog("[XMLTV] Found $count bouquet groups (including 'all')"); - foreach ($ApiDependencyIdentifier as $rGroup => $CacheFlushInterval) { - $FileReference = []; + foreach ($ApiDependencyIdentifier as $rGroup => $CacheFlushInterval) { + $FileReference = []; - foreach ($CacheFlushInterval['bouquets'] as $rBouquetID) { - $db->query('SELECT `bouquet_channels` FROM `bouquets` WHERE `id` = ?;', $rBouquetID); + foreach ($CacheFlushInterval['bouquets'] as $rBouquetID) { + $db->query('SELECT `bouquet_channels` FROM `bouquets` WHERE `id` = ?;', $rBouquetID); - foreach ($db->get_rows() as $rRow) { - $FileReference[] = $rBouquetID; - $ApiDependencyIdentifier[$rGroup]['streams'] = array_merge($ApiDependencyIdentifier[$rGroup]['streams'], json_decode($rRow['bouquet_channels'], true)); - } + foreach ($db->get_rows() as $rRow) { + $FileReference[] = $rBouquetID; + $ApiDependencyIdentifier[$rGroup]['streams'] = array_merge($ApiDependencyIdentifier[$rGroup]['streams'], json_decode($rRow['bouquet_channels'], true)); + } - $ApiDependencyIdentifier[$rGroup]['streams'] = array_unique($ApiDependencyIdentifier[$rGroup]['streams']); - } + $ApiDependencyIdentifier[$rGroup]['streams'] = array_unique($ApiDependencyIdentifier[$rGroup]['streams']); + } - $ApiDependencyIdentifier[$rGroup]['bouquets'] = $FileReference; - } + $ApiDependencyIdentifier[$rGroup]['bouquets'] = $FileReference; + } - return $ApiDependencyIdentifier; - } + return $ApiDependencyIdentifier; + } } diff --git a/src/Cli/CronJobs/ErrorsCronJob.php b/src/Cli/CronJobs/ErrorsCronJob.php index a9678f2b..5d142864 100644 --- a/src/Cli/CronJobs/ErrorsCronJob.php +++ b/src/Cli/CronJobs/ErrorsCronJob.php @@ -17,183 +17,187 @@ use XcVm\Core\Config\SettingsManager; */ class ErrorsCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:errors'; - } + public function getName(): string { + return 'cron:errors'; + } - public function getDescription(): string { - return 'Cron: collect stream and panel errors from logs'; - } + public function getDescription(): string { + return 'Cron: collect stream and panel errors from logs'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->initCron('XC_VM[Errors]'); + $this->initCron('XC_VM[Errors]'); - $rIgnoreErrors = array('the user-agent option is deprecated', 'last message repeated', 'deprecated', 'packets poorly interleaved', 'invalid timestamps', 'timescale not set', 'frame size not set', 'non-monotonous dts in output stream', 'invalid dts', 'no trailing crlf', 'failed to parse extradata', 'truncated', 'missing picture', 'non-existing pps', 'clipping', 'out of range', 'cannot use rename on non file protocol', 'end of file', 'stream ends prematurely'); + $rIgnoreErrors = ['the user-agent option is deprecated', 'last message repeated', 'deprecated', 'packets poorly interleaved', 'invalid timestamps', 'timescale not set', 'frame size not set', 'non-monotonous dts in output stream', 'invalid dts', 'no trailing crlf', 'failed to parse extradata', 'truncated', 'missing picture', 'non-existing pps', 'clipping', 'out of range', 'cannot use rename on non file protocol', 'end of file', 'stream ends prematurely']; - $this->loadCron($rIgnoreErrors); + $this->loadCron($rIgnoreErrors); - return 0; - } + return 0; + } - private function sqlValue($value, bool $isNumeric = false): string { - global $db; + private function sqlValue($value, bool $isNumeric = false): string { + global $db; - if ($value === null || $value === '') { - return 'NULL'; - } - if ($isNumeric) { - if (!is_numeric($value)) { - return 'NULL'; - } - return (string) ((int) $value); - } - return $db->escape($value); - } + if ($value === null || $value === '') { + return 'NULL'; + } + if ($isNumeric) { + if (!is_numeric($value)) { + return 'NULL'; + } + return (string) ((int) $value); + } + return $db->escape($value); + } - private function parseLog(string $logFile): string { - global $db; + private function parseLog(string $logFile): string { + global $db; - if (!file_exists($logFile)) { - return ''; - } + if (!file_exists($logFile)) { + return ''; + } - $fp = fopen($logFile, 'r'); - if (!$fp) { - return ''; - } + $fp = fopen($logFile, 'r'); + if (!$fp) { + return ''; + } - $hashes = []; - $query = ''; + $hashes = []; + $query = ''; - while (!feof($fp)) { - $line = trim(fgets($fp)); - if ($line === '') continue; + while (!feof($fp)) { + $line = trim(fgets($fp)); + if ($line === '') { + continue; + } - $row = json_decode(base64_decode($line), true); - if (!is_array($row)) continue; + $row = json_decode(base64_decode($line), true); + if (!is_array($row)) { + continue; + } - // Поддержка обоих форматов: legacy (log_*) и текущий (message/extra) - $rLogMessage = (string) ($row['log_message'] ?? ($row['message'] ?? '')); - $rLogExtra = (string) ($row['log_extra'] ?? ($row['extra'] ?? '')); - $rLogType = (string) ($row['type'] ?? 'unknown'); - $rLogLine = (int) ($row['line'] ?? 0); - $rLogTime = (int) ($row['time'] ?? time()); - $rLogFile = (string) ($row['file'] ?? ''); - $rLogEnv = (string) ($row['env'] ?? php_sapi_name()); - // Panel version frozen when the error occurred (Logger::log). Empty for - // legacy records written before the field existed. - $rLogVersion = (string) ($row['version'] ?? ''); - // Prefer the origin server stamped into the record (FileLogger); fall - // back to this node's SERVER_ID for legacy files written before the - // field existed. This is what lets a panel_logs row show LB vs MAIN. - $rLogServerID = (isset($row['server_id']) && is_numeric($row['server_id'])) - ? (int) $row['server_id'] - : SERVER_ID; + // Поддержка обоих форматов: legacy (log_*) и текущий (message/extra) + $rLogMessage = (string) ($row['log_message'] ?? ($row['message'] ?? '')); + $rLogExtra = (string) ($row['log_extra'] ?? ($row['extra'] ?? '')); + $rLogType = (string) ($row['type'] ?? 'unknown'); + $rLogLine = (int) ($row['line'] ?? 0); + $rLogTime = (int) ($row['time'] ?? time()); + $rLogFile = (string) ($row['file'] ?? ''); + $rLogEnv = (string) ($row['env'] ?? php_sapi_name()); + // Panel version frozen when the error occurred (Logger::log). Empty for + // legacy records written before the field existed. + $rLogVersion = (string) ($row['version'] ?? ''); + // Prefer the origin server stamped into the record (FileLogger); fall + // back to this node's SERVER_ID for legacy files written before the + // field existed. This is what lets a panel_logs row show LB vs MAIN. + $rLogServerID = (isset($row['server_id']) && is_numeric($row['server_id'])) + ? (int) $row['server_id'] + : SERVER_ID; - if ( - stripos($rLogMessage, 'server has gone away') !== false || - stripos($rLogMessage, 'socket error on read socket') !== false || - stripos($rLogMessage, 'connection lost') !== false - ) { - continue; - } + if ( + stripos($rLogMessage, 'server has gone away') !== false || + stripos($rLogMessage, 'socket error on read socket') !== false || + stripos($rLogMessage, 'connection lost') !== false + ) { + continue; + } - // server_id is part of the key so the SAME error from LB and MAIN keeps - // two distinct panel_logs rows — INSERT IGNORE on `unique` would - // otherwise collapse them into one and lose the per-server attribution. - $hash = md5( - $rLogServerID . - $rLogType . - $rLogMessage . - $rLogExtra . - $rLogFile . - $rLogLine - ); + // server_id is part of the key so the SAME error from LB and MAIN keeps + // two distinct panel_logs rows — INSERT IGNORE on `unique` would + // otherwise collapse them into one and lose the per-server attribution. + $hash = md5( + $rLogServerID . + $rLogType . + $rLogMessage . + $rLogExtra . + $rLogFile . + $rLogLine + ); - if (isset($hashes[$hash])) { - continue; - } - $hashes[$hash] = true; + if (isset($hashes[$hash])) { + continue; + } + $hashes[$hash] = true; - $query .= sprintf( - "(%d,%s,%s,%s,%s,%s,%s,%s,%s,%s),", - $rLogServerID, - $this->sqlValue($rLogType), - $this->sqlValue($rLogMessage), - $this->sqlValue($rLogExtra), - $this->sqlValue($rLogLine, true), - $this->sqlValue($rLogTime, true), - $this->sqlValue($rLogFile), - $this->sqlValue($rLogEnv), - $this->sqlValue($rLogVersion), - $this->sqlValue($hash) - ); - } + $query .= sprintf( + "(%d,%s,%s,%s,%s,%s,%s,%s,%s,%s),", + $rLogServerID, + $this->sqlValue($rLogType), + $this->sqlValue($rLogMessage), + $this->sqlValue($rLogExtra), + $this->sqlValue($rLogLine, true), + $this->sqlValue($rLogTime, true), + $this->sqlValue($rLogFile), + $this->sqlValue($rLogEnv), + $this->sqlValue($rLogVersion), + $this->sqlValue($hash) + ); + } - fclose($fp); + fclose($fp); - return rtrim($query, ','); - } + return rtrim($query, ','); + } - private function inArray(array $needles, string $haystack): bool { - foreach ($needles as $needle) { - if (stristr($haystack, $needle)) { - return true; - } - } - return false; - } + private function inArray(array $needles, string $haystack): bool { + foreach ($needles as $needle) { + if (stristr($haystack, $needle)) { + return true; + } + } + return false; + } - private function loadCron(array $rIgnoreErrors): void { - global $db; + private function loadCron(array $rIgnoreErrors): void { + global $db; - $rQuery = ''; - foreach (array(STREAMS_PATH) as $rPath) { - if ($rHandle = opendir($rPath)) { - while (false !== ($fileEntry = readdir($rHandle))) { - if ($fileEntry != '.' && $fileEntry != '..' && is_file($rPath . $fileEntry)) { - $rFile = $rPath . $fileEntry; - $rPathInfo = pathinfo($fileEntry); - $rStreamID = (int) ($rPathInfo['filename'] ?? 0); - $rExtension = $rPathInfo['extension'] ?? ''; - if ($rExtension == 'errors' && 0 < $rStreamID) { - $rErrors = preg_split('/\r\n|\r|\n/', (string) file_get_contents($rFile)); - foreach ($rErrors as $rError) { - $rError = trim((string) $rError); - if (!(empty($rError) || $this->inArray($rIgnoreErrors, $rError))) { - if (SettingsManager::get('stream_logs_save')) { - $rQuery .= '(' . $rStreamID . ',' . SERVER_ID . ',' . time() . ',' . $db->escape($rError) . '),'; - } - } - } - unlink($rFile); - } - } - } - closedir($rHandle); - } - } + $rQuery = ''; + foreach ([STREAMS_PATH] as $rPath) { + if ($rHandle = opendir($rPath)) { + while (false !== ($fileEntry = readdir($rHandle))) { + if ($fileEntry != '.' && $fileEntry != '..' && is_file($rPath . $fileEntry)) { + $rFile = $rPath . $fileEntry; + $rPathInfo = pathinfo($fileEntry); + $rStreamID = (int) ($rPathInfo['filename'] ?? 0); + $rExtension = $rPathInfo['extension'] ?? ''; + if ($rExtension == 'errors' && 0 < $rStreamID) { + $rErrors = preg_split('/\r\n|\r|\n/', (string) file_get_contents($rFile)); + foreach ($rErrors as $rError) { + $rError = trim((string) $rError); + if (!(empty($rError) || $this->inArray($rIgnoreErrors, $rError))) { + if (SettingsManager::get('stream_logs_save')) { + $rQuery .= '(' . $rStreamID . ',' . SERVER_ID . ',' . time() . ',' . $db->escape($rError) . '),'; + } + } + } + unlink($rFile); + } + } + } + closedir($rHandle); + } + } - if (SettingsManager::get('stream_logs_save') && !empty($rQuery)) { - $rQuery = rtrim($rQuery, ','); - $db->query('INSERT INTO `streams_errors` (`stream_id`,`server_id`,`date`,`error`) VALUES ' . $rQuery . ';'); - } + if (SettingsManager::get('stream_logs_save') && !empty($rQuery)) { + $rQuery = rtrim($rQuery, ','); + $db->query('INSERT INTO `streams_errors` (`stream_id`,`server_id`,`date`,`error`) VALUES ' . $rQuery . ';'); + } - $rLog = LOGS_TMP_PATH . 'error_log.log'; - if (file_exists($rLog)) { - $rQuery = $this->parseLog(LOGS_TMP_PATH . 'error_log.log'); - if ($rQuery !== '') { - $rInserted = $db->query("INSERT IGNORE INTO panel_logs(server_id, type, log_message, log_extra, line, date, file, env, version, `unique`) VALUES {$rQuery};"); - if ($rInserted) { - unlink($rLog); - } - } - } - } + $rLog = LOGS_TMP_PATH . 'error_log.log'; + if (file_exists($rLog)) { + $rQuery = $this->parseLog(LOGS_TMP_PATH . 'error_log.log'); + if ($rQuery !== '') { + $rInserted = $db->query("INSERT IGNORE INTO panel_logs(server_id, type, log_message, log_extra, line, date, file, env, version, `unique`) VALUES {$rQuery};"); + if ($rInserted) { + unlink($rLog); + } + } + } + } } diff --git a/src/Cli/CronJobs/LinesLogsCronJob.php b/src/Cli/CronJobs/LinesLogsCronJob.php index 482df46a..a47cd683 100644 --- a/src/Cli/CronJobs/LinesLogsCronJob.php +++ b/src/Cli/CronJobs/LinesLogsCronJob.php @@ -17,57 +17,57 @@ use XcVm\Infrastructure\Database\DatabaseAware; */ class LinesLogsCronJob implements CommandInterface { - use DatabaseAware; - use CronTrait; + use DatabaseAware; + use CronTrait; - public function getName(): string { - return 'cron:lines_logs'; - } + public function getName(): string { + return 'cron:lines_logs'; + } - public function getDescription(): string { - return 'Cron: import client request logs into DB'; - } + public function getDescription(): string { + return 'Cron: import client request logs into DB'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->initCron('XC_VM[Lines Logs]'); - $this->loadCron(); + $this->initCron('XC_VM[Lines Logs]'); + $this->loadCron(); - return 0; - } + return 0; + } - private function loadCron(): void { - $db = self::db(); + private function loadCron(): void { + $db = self::db(); - $rLog = LOGS_TMP_PATH . 'client_request.log'; - if (!file_exists($rLog)) { - return; - } + $rLog = LOGS_TMP_PATH . 'client_request.log'; + if (!file_exists($rLog)) { + return; + } - $rQuery = rtrim($this->parseLog($rLog), ','); - if (!empty($rQuery)) { - $db->query('INSERT INTO `lines_logs` (`stream_id`,`user_id`,`client_status`,`query_string`,`user_agent`,`ip`,`extra_data`,`date`) VALUES ' . $rQuery . ';'); - } - unlink($rLog); - } + $rQuery = rtrim($this->parseLog($rLog), ','); + if (!empty($rQuery)) { + $db->query('INSERT INTO `lines_logs` (`stream_id`,`user_id`,`client_status`,`query_string`,`user_agent`,`ip`,`extra_data`,`date`) VALUES ' . $rQuery . ';'); + } + unlink($rLog); + } - private function parseLog(string $rLog): string { - $db = self::db(); - $rQuery = ''; - $rFP = fopen($rLog, 'r'); - while (!feof($rFP)) { - $rLine = trim(fgets($rFP)); - if (!empty($rLine)) { - $rLine = json_decode(base64_decode($rLine), true); - $rLine = array_map(array($db, 'escape'), $rLine); - $rQuery .= '(' . $rLine['stream_id'] . ',' . $rLine['user_id'] . ',' . $rLine['action'] . ',' . $rLine['query_string'] . ',' . $rLine['user_agent'] . ',' . $rLine['user_ip'] . ',' . $rLine['extra_data'] . ',' . $rLine['time'] . '),'; - break; - } - } - fclose($rFP); - return $rQuery; - } + private function parseLog(string $rLog): string { + $db = self::db(); + $rQuery = ''; + $rFP = fopen($rLog, 'r'); + while (!feof($rFP)) { + $rLine = trim(fgets($rFP)); + if (!empty($rLine)) { + $rLine = json_decode(base64_decode($rLine), true); + $rLine = array_map([$db, 'escape'], $rLine); + $rQuery .= '(' . $rLine['stream_id'] . ',' . $rLine['user_id'] . ',' . $rLine['action'] . ',' . $rLine['query_string'] . ',' . $rLine['user_agent'] . ',' . $rLine['user_ip'] . ',' . $rLine['extra_data'] . ',' . $rLine['time'] . '),'; + break; + } + } + fclose($rFP); + return $rQuery; + } } diff --git a/src/Cli/CronJobs/ModuleLicensesCronJob.php b/src/Cli/CronJobs/ModuleLicensesCronJob.php index 5656889d..47a7f197 100644 --- a/src/Cli/CronJobs/ModuleLicensesCronJob.php +++ b/src/Cli/CronJobs/ModuleLicensesCronJob.php @@ -25,63 +25,63 @@ use XcVm\Core\Module\ModuleManager; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class ModuleLicensesCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:module_licenses'; - } + public function getName(): string { + return 'cron:module_licenses'; + } - public function getDescription(): string { - return 'Cron: renew per-machine ionCube licenses for platform modules'; - } + public function getDescription(): string { + return 'Cron: renew per-machine ionCube licenses for platform modules'; + } - public function execute(array $rArgs): int { - $this->setProcessTitle('xc_vm: module licenses'); + public function execute(array $rArgs): int { + $this->setProcessTitle('xc_vm: module licenses'); - // Licensing only applies when the ionCube loader + extension are present. - if (!function_exists('ioncube_server_data') || !class_exists('XC_VM')) { - echo "ionCube loader/extension not present — nothing to do.\n"; - return 0; - } + // Licensing only applies when the ionCube loader + extension are present. + if (!function_exists('ioncube_server_data') || !class_exists('XC_VM')) { + echo "ionCube loader/extension not present — nothing to do.\n"; + return 0; + } - $apiKey = (string) (SettingsManager::get('platform_api_key') ?? ''); - if ($apiKey === '') { - echo "No platform API key configured — skipping.\n"; - return 0; - } + $apiKey = (string) (SettingsManager::get('platform_api_key') ?? ''); + if ($apiKey === '') { + echo "No platform API key configured — skipping.\n"; + return 0; + } - $manager = new ModuleManager(container: ServiceContainer::getInstance()); + $manager = new ModuleManager(container: ServiceContainer::getInstance()); - $platform = array_filter( - $manager->listModules(), - static fn ($m) => ($m['source'] ?? '') === 'platform' - ); - if (empty($platform)) { - echo "No platform modules installed.\n"; - return 0; - } + $platform = array_filter( + $manager->listModules(), + static fn ($m) => ($m['source'] ?? '') === 'platform' + ); + if (empty($platform)) { + echo "No platform modules installed.\n"; + return 0; + } - $renewed = 0; - $skipped = 0; - foreach ($platform as $m) { - $slug = (string) ($m['name'] ?? ''); - $dir = (string) ($m['path'] ?? ''); + $renewed = 0; + $skipped = 0; + foreach ($platform as $m) { + $slug = (string) ($m['name'] ?? ''); + $dir = (string) ($m['path'] ?? ''); - // Only renew modules that are actually licensed (carry a .lic). - if ($slug === '' || $dir === '' || empty(glob($dir . '/*.lic'))) { - continue; - } + // Only renew modules that are actually licensed (carry a .lic). + if ($slug === '' || $dir === '' || empty(glob($dir . '/*.lic'))) { + continue; + } - if ($manager->renewModuleLicense($slug, $apiKey)) { - echo "[OK] {$slug}: license renewed\n"; - $renewed++; - } else { - echo "[WARN] {$slug}: not renewed (licensing off / not entitled / error)\n"; - $skipped++; - } - } + if ($manager->renewModuleLicense($slug, $apiKey)) { + echo "[OK] {$slug}: license renewed\n"; + $renewed++; + } else { + echo "[WARN] {$slug}: not renewed (licensing off / not entitled / error)\n"; + $skipped++; + } + } - echo "Done: {$renewed} renewed, {$skipped} skipped.\n"; - return 0; - } + echo "Done: {$renewed} renewed, {$skipped} skipped.\n"; + return 0; + } } diff --git a/src/Cli/CronJobs/ModuleUpdatesCronJob.php b/src/Cli/CronJobs/ModuleUpdatesCronJob.php index 44215a3f..b66077a7 100644 --- a/src/Cli/CronJobs/ModuleUpdatesCronJob.php +++ b/src/Cli/CronJobs/ModuleUpdatesCronJob.php @@ -29,49 +29,48 @@ use XcVm\Core\Module\ModuleUpdateChecker; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class ModuleUpdatesCronJob implements CommandInterface { + public function getName(): string { + return 'cron:module_updates'; + } - public function getName(): string { - return 'cron:module_updates'; - } + public function getDescription(): string { + return 'Cron: check module update availability from their declared sources'; + } - public function getDescription(): string { - return 'Cron: check module update availability from their declared sources'; - } + public function execute(array $rArgs): int { + register_shutdown_function(function () { + global $db; + if (is_object($db)) { + $db->close_mysql(); + } + }); - public function execute(array $rArgs): int { - register_shutdown_function(function () { - global $db; - if (is_object($db)) { - $db->close_mysql(); - } - }); + $rManager = new ModuleManager(container: ServiceContainer::getInstance()); + $rChecker = new ModuleUpdateChecker(); - $rManager = new ModuleManager(container: ServiceContainer::getInstance()); - $rChecker = new ModuleUpdateChecker(); + foreach ($rManager->listModules() as $rModule) { + // Only installed modules — the check compares against installed_version. + if (($rModule['installed_version'] ?? '') === '') { + continue; + } - foreach ($rManager->listModules() as $rModule) { - // Only installed modules — the check compares against installed_version. - if (($rModule['installed_version'] ?? '') === '') { - continue; - } + $rInstalled = (string) $rModule['installed_version']; + $rLatest = $rChecker->latestAvailable($rModule); - $rInstalled = (string) $rModule['installed_version']; - $rLatest = $rChecker->latestAvailable($rModule); + if ($rLatest !== null && version_compare($rLatest, $rInstalled, '>')) { + $rManager->recordAvailableVersion($rModule['name'], $rLatest); + echo '[UPDATE] ' . $rModule['name'] . ': ' . $rInstalled . ' -> ' . $rLatest . "\n"; + } elseif ($rChecker->lastError() !== null) { + // Source unreachable (rate limit, network) — keep any previously + // recorded flag; clearing here would hide a real update until the + // next successful check. + echo '[SKIP] ' . $rModule['name'] . ': ' . $rChecker->lastError() . "\n"; + } else { + // Nothing newer — clear any stale flag. + $rManager->recordAvailableVersion($rModule['name'], null); + } + } - if ($rLatest !== null && version_compare($rLatest, $rInstalled, '>')) { - $rManager->recordAvailableVersion($rModule['name'], $rLatest); - echo '[UPDATE] ' . $rModule['name'] . ': ' . $rInstalled . ' -> ' . $rLatest . "\n"; - } elseif ($rChecker->lastError() !== null) { - // Source unreachable (rate limit, network) — keep any previously - // recorded flag; clearing here would hide a real update until the - // next successful check. - echo '[SKIP] ' . $rModule['name'] . ': ' . $rChecker->lastError() . "\n"; - } else { - // Nothing newer — clear any stale flag. - $rManager->recordAvailableVersion($rModule['name'], null); - } - } - - return 0; - } + return 0; + } } diff --git a/src/Cli/CronJobs/ProvidersCronJob.php b/src/Cli/CronJobs/ProvidersCronJob.php index e6a9007b..681970d0 100644 --- a/src/Cli/CronJobs/ProvidersCronJob.php +++ b/src/Cli/CronJobs/ProvidersCronJob.php @@ -17,168 +17,182 @@ use XcVm\Domain\Stream\StreamProcess; */ class ProvidersCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:providers'; - } + public function getName(): string { + return 'cron:providers'; + } - public function getDescription(): string { - return 'Cron: sync providers (channels, VOD, series)'; - } + public function getDescription(): string { + return 'Cron: sync providers (channels, VOD, series)'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->initCron('XC_VM[Providers]'); + $this->initCron('XC_VM[Providers]'); - $rTimeout = 300; - set_time_limit($rTimeout); - ini_set('max_execution_time', $rTimeout); + $rTimeout = 300; + set_time_limit($rTimeout); + ini_set('max_execution_time', $rTimeout); - $rProviderID = null; - if (!empty($rArgs[0])) { - $rProviderID = intval($rArgs[0]); - } + $rProviderID = null; + if (!empty($rArgs[0])) { + $rProviderID = intval($rArgs[0]); + } - $this->loadCron($rProviderID); + $this->loadCron($rProviderID); - return 0; - } + return 0; + } - private function readURL(string $rURL): ?array { - $rContext = stream_context_create(array('http' => array('timeout' => 30))); - return json_decode(@file_get_contents($rURL, false, $rContext) ?: 'null', true); - } + private function readURL(string $rURL): ?array { + $rContext = stream_context_create(['http' => ['timeout' => 30]]); + return json_decode(@file_get_contents($rURL, false, $rContext) ?: 'null', true); + } - private function loadCron(?int $rProviderID): void { - global $db; + private function loadCron(?int $rProviderID): void { + global $db; - if ($rProviderID) { - $db->query('SELECT `id`, `stream_display_name`, `title_sync` FROM `streams` WHERE `title_sync` LIKE ?;', $rProviderID . '_%'); - } else { - $db->query('SELECT `id`, `stream_display_name`, `title_sync` FROM `streams` WHERE `title_sync` IS NOT NULL;'); - } + if ($rProviderID) { + $db->query('SELECT `id`, `stream_display_name`, `title_sync` FROM `streams` WHERE `title_sync` LIKE ?;', $rProviderID . '_%'); + } else { + $db->query('SELECT `id`, `stream_display_name`, `title_sync` FROM `streams` WHERE `title_sync` IS NOT NULL;'); + } - $rSyncTitle = array(); - foreach ($db->get_rows() as $rRow) { - list($rSyncID, $rSyncStream) = array_map('intval', explode('_', $rRow['title_sync'])); - if (!isset($rSyncTitle[$rSyncID])) { - $rSyncTitle[$rSyncID] = array(); - } - $rSyncTitle[$rSyncID][$rSyncStream] = array($rRow['id'], $rRow['stream_display_name']); - } + $rSyncTitle = []; + foreach ($db->get_rows() as $rRow) { + list($rSyncID, $rSyncStream) = array_map('intval', explode('_', $rRow['title_sync'])); + if (!isset($rSyncTitle[$rSyncID])) { + $rSyncTitle[$rSyncID] = []; + } + $rSyncTitle[$rSyncID][$rSyncStream] = [$rRow['id'], $rRow['stream_display_name']]; + } - if ($rProviderID) { - $db->query('SELECT * FROM `providers` WHERE `id` = ?;', $rProviderID); - } else { - $db->query('SELECT * FROM `providers` WHERE `enabled` = 1;'); - } + if ($rProviderID) { + $db->query('SELECT * FROM `providers` WHERE `id` = ?;', $rProviderID); + } else { + $db->query('SELECT * FROM `providers` WHERE `enabled` = 1;'); + } - foreach ($db->get_rows() as $rRow) { - $rArray = array(); - $rURL = (($rRow['ssl'] ? 'https' : 'http')) . '://' . $rRow['ip'] . ':' . $rRow['port'] . '/'; - if ($rRow['legacy']) { - $rURL .= 'player_api.php?username=' . $rRow['username'] . '&password=' . $rRow['password']; - } else { - $rURL .= 'player_api/' . $rRow['username'] . '/' . $rRow['password'] . '?connections=1'; - } + foreach ($db->get_rows() as $rRow) { + $rArray = []; + $rURL = (($rRow['ssl'] ? 'https' : 'http')) . '://' . $rRow['ip'] . ':' . $rRow['port'] . '/'; + if ($rRow['legacy']) { + $rURL .= 'player_api.php?username=' . $rRow['username'] . '&password=' . $rRow['password']; + } else { + $rURL .= 'player_api/' . $rRow['username'] . '/' . $rRow['password'] . '?connections=1'; + } - $rInfo = $this->readURL($rURL); - if ($rInfo) { - $rStatus = 1; - $rUserInfo = $rInfo['user_info'] ?? []; - $rArray['max_connections'] = $rUserInfo['max_connections'] ?? null; - $rArray['active_connections'] = $rUserInfo['active_cons'] ?? 0; - $rArray['exp_date'] = $rUserInfo['exp_date'] ?? null; - } else { - $rStatus = 0; - $rArray['exp_date'] = ($rRow['exp_date'] ?: -1); - } + $rInfo = $this->readURL($rURL); + if ($rInfo) { + $rStatus = 1; + $rUserInfo = $rInfo['user_info'] ?? []; + $rArray['max_connections'] = $rUserInfo['max_connections'] ?? null; + $rArray['active_connections'] = $rUserInfo['active_cons'] ?? 0; + $rArray['exp_date'] = $rUserInfo['exp_date'] ?? null; + } else { + $rStatus = 0; + $rArray['exp_date'] = ($rRow['exp_date'] ?: -1); + } - $rCategories = array(); - $rCategoriesURL = $rURL . '&action=get_live_categories'; - $rLiveCategories = $this->readURL($rCategoriesURL); - if (!is_array($rLiveCategories)) $rLiveCategories = []; - foreach ($rLiveCategories as $rCategory) { - if (isset($rCategory['category_id'])) { $rCategories[$rCategory['category_id']] = $rCategory['category_name'] ?? ''; } - } - $rCategoriesURL = $rURL . '&action=get_vod_categories'; - $rVodCategories = $this->readURL($rCategoriesURL); - if (!is_array($rVodCategories)) $rVodCategories = []; - foreach ($rVodCategories as $rCategory) { - if (isset($rCategory['category_id'])) { $rCategories[$rCategory['category_id']] = $rCategory['category_name'] ?? ''; } - } + $rCategories = []; + $rCategoriesURL = $rURL . '&action=get_live_categories'; + $rLiveCategories = $this->readURL($rCategoriesURL); + if (!is_array($rLiveCategories)) { + $rLiveCategories = []; + } + foreach ($rLiveCategories as $rCategory) { + if (isset($rCategory['category_id'])) { + $rCategories[$rCategory['category_id']] = $rCategory['category_name'] ?? ''; + } + } + $rCategoriesURL = $rURL . '&action=get_vod_categories'; + $rVodCategories = $this->readURL($rCategoriesURL); + if (!is_array($rVodCategories)) { + $rVodCategories = []; + } + foreach ($rVodCategories as $rCategory) { + if (isset($rCategory['category_id'])) { + $rCategories[$rCategory['category_id']] = $rCategory['category_name'] ?? ''; + } + } - $rStreamsURL = $rURL . '&action=get_live_streams'; - $rStreams = $this->readURL($rStreamsURL); - if (!is_array($rStreams)) $rStreams = []; - $rArray['streams'] = count($rStreams); + $rStreamsURL = $rURL . '&action=get_live_streams'; + $rStreams = $this->readURL($rStreamsURL); + if (!is_array($rStreams)) { + $rStreams = []; + } + $rArray['streams'] = count($rStreams); - $rVODURL = $rURL . '&action=get_vod_streams'; - $rVOD = $this->readURL($rVODURL); - if (!is_array($rVOD)) $rVOD = []; - $rArray['movies'] = count($rVOD); + $rVODURL = $rURL . '&action=get_vod_streams'; + $rVOD = $this->readURL($rVODURL); + if (!is_array($rVOD)) { + $rVOD = []; + } + $rArray['movies'] = count($rVOD); - $rSeriesURL = $rURL . '&action=get_series'; - $rSeries = $this->readURL($rSeriesURL); - if (!is_array($rSeries)) $rSeries = []; - $rArray['series'] = count($rSeries); + $rSeriesURL = $rURL . '&action=get_series'; + $rSeries = $this->readURL($rSeriesURL); + if (!is_array($rSeries)) { + $rSeries = []; + } + $rArray['series'] = count($rSeries); - $rLastChanged = time(); - $db->query('UPDATE `providers` SET `data` = ?, `last_changed` = ?, `status` = ? WHERE `id` = ?;', json_encode($rArray), $rLastChanged, $rStatus, $rRow['id']); + $rLastChanged = time(); + $db->query('UPDATE `providers` SET `data` = ?, `last_changed` = ?, `status` = ? WHERE `id` = ?;', json_encode($rArray), $rLastChanged, $rStatus, $rRow['id']); - $db->query('SELECT `type`, `stream_id`, `category_id`, `stream_display_name`, `stream_icon`, `channel_id` FROM `providers_streams` WHERE `provider_id` = ?;', $rRow['id']); - $rNewIDs = $rExistingIDs = array(); - foreach ($db->get_rows() as $rStream) { - $rExistingIDs[$rStream['stream_id']] = md5($rStream['category_id'] . '_' . (($rStream['stream_display_name'] ?: '')) . '_' . (($rStream['stream_icon'] ?: '')) . '_' . (($rStream['channel_id'] ?: ''))); - } + $db->query('SELECT `type`, `stream_id`, `category_id`, `stream_display_name`, `stream_icon`, `channel_id` FROM `providers_streams` WHERE `provider_id` = ?;', $rRow['id']); + $rNewIDs = $rExistingIDs = []; + foreach ($db->get_rows() as $rStream) { + $rExistingIDs[$rStream['stream_id']] = md5($rStream['category_id'] . '_' . (($rStream['stream_display_name'] ?: '')) . '_' . (($rStream['stream_icon'] ?: '')) . '_' . (($rStream['channel_id'] ?: ''))); + } - $rTime = time(); - foreach (array('live' => $rStreams, 'movie' => $rVOD) as $rType => $rSelection) { - foreach ($rSelection as $rStream) { - // External provider payloads may omit any of these keys. - $rStream += array('stream_id' => null, 'category_id' => '', 'name' => '', 'stream_icon' => '', 'epg_channel_id' => '', 'container_extension' => ''); - if ($rStream['stream_id'] === null) { - continue; - } - $rNewIDs[] = $rStream['stream_id']; - $rCategoryIDs = (isset($rStream['category_ids']) ? (is_array($rStream['category_ids']) ? $rStream['category_ids'] : array()) : array($rStream['category_id'])); - $rCategoryArray = array(); - foreach ($rCategoryIDs as $rCategoryID) { - // The provider may reference a category id absent from the - // categories feed; default to '' instead of warning. - $rCategoryArray[] = $rCategories[$rCategoryID] ?? ''; - } - $rCategoryIDs = '[' . implode(',', array_map('intval', $rCategoryIDs)) . ']'; - if (isset($rExistingIDs[$rStream['stream_id']])) { - $rUUID = $rExistingIDs[$rStream['stream_id']]; - if (md5($rCategoryIDs . '_' . (($rStream['name'] ?: '')) . '_' . (($rStream['stream_icon'] ?: '')) . '_' . ((($rType == 'live' ? $rStream['epg_channel_id'] : $rStream['container_extension']) ?: ''))) != $rUUID) { - $db->query('UPDATE `providers_streams` SET `category_id` = ?, `category_array` = ?, `stream_display_name` = ?, `stream_icon` = ?, `channel_id` = ?, `modified` = ? WHERE `provider_id` = ? AND `stream_id` = ?;', $rCategoryIDs, json_encode($rCategoryArray), $rStream['name'], $rStream['stream_icon'], ($rType == 'live' ? $rStream['epg_channel_id'] : $rStream['container_extension']), $rTime, $rRow['id'], $rStream['stream_id']); - } - } else { - $db->query('INSERT INTO `providers_streams`(`provider_id`, `type`, `stream_id`, `category_id`, `category_array`, `stream_display_name`, `stream_icon`, `channel_id`, `added`, `modified`) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?);', $rRow['id'], $rType, $rStream['stream_id'], $rCategoryIDs, json_encode($rCategoryArray), $rStream['name'], $rStream['stream_icon'], ($rType == 'live' ? $rStream['epg_channel_id'] : $rStream['container_extension']), $rTime, $rTime); - } - if ($rType == 'live' && isset($rSyncTitle[$rRow['id']][$rStream['stream_id']])) { - if ($rStream['name'] != $rSyncTitle[$rRow['id']][$rStream['stream_id']][1]) { - $db->query('UPDATE `streams` SET `stream_display_name` = ? WHERE `id` = ?;', $rStream['name'], $rSyncTitle[$rRow['id']][$rStream['stream_id']][0]); - StreamProcess::updateStream($rSyncTitle[$rRow['id']][$rStream['stream_id']][0]); - } - } - } - } + $rTime = time(); + foreach (['live' => $rStreams, 'movie' => $rVOD] as $rType => $rSelection) { + foreach ($rSelection as $rStream) { + // External provider payloads may omit any of these keys. + $rStream += ['stream_id' => null, 'category_id' => '', 'name' => '', 'stream_icon' => '', 'epg_channel_id' => '', 'container_extension' => '']; + if ($rStream['stream_id'] === null) { + continue; + } + $rNewIDs[] = $rStream['stream_id']; + $rCategoryIDs = (isset($rStream['category_ids']) ? (is_array($rStream['category_ids']) ? $rStream['category_ids'] : []) : [$rStream['category_id']]); + $rCategoryArray = []; + foreach ($rCategoryIDs as $rCategoryID) { + // The provider may reference a category id absent from the + // categories feed; default to '' instead of warning. + $rCategoryArray[] = $rCategories[$rCategoryID] ?? ''; + } + $rCategoryIDs = '[' . implode(',', array_map('intval', $rCategoryIDs)) . ']'; + if (isset($rExistingIDs[$rStream['stream_id']])) { + $rUUID = $rExistingIDs[$rStream['stream_id']]; + if (md5($rCategoryIDs . '_' . (($rStream['name'] ?: '')) . '_' . (($rStream['stream_icon'] ?: '')) . '_' . ((($rType == 'live' ? $rStream['epg_channel_id'] : $rStream['container_extension']) ?: ''))) != $rUUID) { + $db->query('UPDATE `providers_streams` SET `category_id` = ?, `category_array` = ?, `stream_display_name` = ?, `stream_icon` = ?, `channel_id` = ?, `modified` = ? WHERE `provider_id` = ? AND `stream_id` = ?;', $rCategoryIDs, json_encode($rCategoryArray), $rStream['name'], $rStream['stream_icon'], ($rType == 'live' ? $rStream['epg_channel_id'] : $rStream['container_extension']), $rTime, $rRow['id'], $rStream['stream_id']); + } + } else { + $db->query('INSERT INTO `providers_streams`(`provider_id`, `type`, `stream_id`, `category_id`, `category_array`, `stream_display_name`, `stream_icon`, `channel_id`, `added`, `modified`) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?);', $rRow['id'], $rType, $rStream['stream_id'], $rCategoryIDs, json_encode($rCategoryArray), $rStream['name'], $rStream['stream_icon'], ($rType == 'live' ? $rStream['epg_channel_id'] : $rStream['container_extension']), $rTime, $rTime); + } + if ($rType == 'live' && isset($rSyncTitle[$rRow['id']][$rStream['stream_id']])) { + if ($rStream['name'] != $rSyncTitle[$rRow['id']][$rStream['stream_id']][1]) { + $db->query('UPDATE `streams` SET `stream_display_name` = ? WHERE `id` = ?;', $rStream['name'], $rSyncTitle[$rRow['id']][$rStream['stream_id']][0]); + StreamProcess::updateStream($rSyncTitle[$rRow['id']][$rStream['stream_id']][0]); + } + } + } + } - $rDelete = array(); - foreach (array_keys($rExistingIDs) as $rStreamID) { - if (!in_array($rStreamID, $rNewIDs)) { - $rDelete[] = $rStreamID; - } - } - if (count($rDelete) > 0) { - $db->query('DELETE FROM `providers_streams` WHERE `provider_id` = ? AND `stream_id` IN (' . implode(',', array_map('intval', $rDelete)) . ');', $rRow['id']); - } - } - } + $rDelete = []; + foreach (array_keys($rExistingIDs) as $rStreamID) { + if (!in_array($rStreamID, $rNewIDs)) { + $rDelete[] = $rStreamID; + } + } + if (count($rDelete) > 0) { + $db->query('DELETE FROM `providers_streams` WHERE `provider_id` = ? AND `stream_id` IN (' . implode(',', array_map('intval', $rDelete)) . ');', $rRow['id']); + } + } + } } diff --git a/src/Cli/CronJobs/RootMysqlCronJob.php b/src/Cli/CronJobs/RootMysqlCronJob.php index 119cf4e1..069eefcf 100644 --- a/src/Cli/CronJobs/RootMysqlCronJob.php +++ b/src/Cli/CronJobs/RootMysqlCronJob.php @@ -19,161 +19,161 @@ use XcVm\Domain\Server\ServerRepository; */ class RootMysqlCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:root_mysql'; - } + public function getName(): string { + return 'cron:root_mysql'; + } - public function getDescription(): string { - return 'Cron: monitor MariaDB, parse syslog, block bruteforce (root)'; - } + public function getDescription(): string { + return 'Cron: monitor MariaDB, parse syslog, block bruteforce (root)'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsRoot()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsRoot()) { + return 1; + } - if (!$this->checkMariaDB()) { - return 1; - } + if (!$this->checkMariaDB()) { + return 1; + } - global $db; + global $db; - $this->setProcessTitle('XC_VM[MysqlErrors]'); - $this->acquireCronLock(); + $this->setProcessTitle('XC_VM[MysqlErrors]'); + $this->acquireCronLock(); - $rIgnoreErrors = array('innodb: page_cleaner', 'aborted connection', 'got an error reading communication packets', 'got packets out of order', 'got timeout reading communication packets'); + $rIgnoreErrors = ['innodb: page_cleaner', 'aborted connection', 'got an error reading communication packets', 'got packets out of order', 'got timeout reading communication packets']; - if (SettingsManager::get('mysql_sleep_kill') > 0) { - $db->query("SELECT `id` FROM `INFORMATION_SCHEMA`.`PROCESSLIST` WHERE `COMMAND` = 'Sleep' AND `TIME` > ?;", intval(SettingsManager::get('mysql_sleep_kill'))); - foreach ($db->get_rows() as $rRow) { - $db->query('KILL ?;', $rRow['id']); - } - } + if (SettingsManager::get('mysql_sleep_kill') > 0) { + $db->query("SELECT `id` FROM `INFORMATION_SCHEMA`.`PROCESSLIST` WHERE `COMMAND` = 'Sleep' AND `TIME` > ?;", intval(SettingsManager::get('mysql_sleep_kill'))); + foreach ($db->get_rows() as $rRow) { + $db->query('KILL ?;', $rRow['id']); + } + } - $db->query('SELECT MAX(`date`) AS `date` FROM `mysql_syslog`;'); - $rMaxTime = intval($db->get_row()['date']); + $db->query('SELECT MAX(`date`) AS `date` FROM `mysql_syslog`;'); + $rMaxTime = intval($db->get_row()['date']); - $rMaxAttempts = 10; - $rAttempts = array(); + $rMaxAttempts = 10; + $rAttempts = []; - $db->query("SELECT `mysql_syslog`.`ip`, COUNT(`mysql_syslog`.`id`) AS `count`, `blocked_ips`.`id` AS `block_id` FROM `mysql_syslog` LEFT JOIN `blocked_ips` ON `blocked_ips`.`ip` = `mysql_syslog`.`ip` WHERE `type` = 'AUTH' AND `mysql_syslog`.`date` > UNIX_TIMESTAMP() - 86400 GROUP BY `mysql_syslog`.`ip`;"); - foreach ($db->get_rows() as $rRow) { - $rAttempts[$rRow['ip']] = $rRow['count']; - if ($rMaxAttempts < $rRow['count'] && !$rRow['block_id']) { - if (!in_array($rRow['ip'], ServerRepository::getAllowedIPs())) { - echo 'Blocking IP ' . $rRow['ip'] . "\n"; - BlocklistService::blockIP(array('ip' => $rRow['ip'], 'notes' => 'MYSQL BRUTEFORCE ATTACK')); - } - } - } - - // Fast-path: skip expensive syslog tail/grep when file size has not changed. - $rSyslogMarker = CRONS_TMP_PATH . 'mysql_syslog_size'; - $rCurrentSize = @filesize('/var/log/syslog') ?: 0; - $rLastSize = file_exists($rSyslogMarker) ? intval(@file_get_contents($rSyslogMarker)) : -1; + $db->query("SELECT `mysql_syslog`.`ip`, COUNT(`mysql_syslog`.`id`) AS `count`, `blocked_ips`.`id` AS `block_id` FROM `mysql_syslog` LEFT JOIN `blocked_ips` ON `blocked_ips`.`ip` = `mysql_syslog`.`ip` WHERE `type` = 'AUTH' AND `mysql_syslog`.`date` > UNIX_TIMESTAMP() - 86400 GROUP BY `mysql_syslog`.`ip`;"); + foreach ($db->get_rows() as $rRow) { + $rAttempts[$rRow['ip']] = $rRow['count']; + if ($rMaxAttempts < $rRow['count'] && !$rRow['block_id']) { + if (!in_array($rRow['ip'], ServerRepository::getAllowedIPs())) { + echo 'Blocking IP ' . $rRow['ip'] . "\n"; + BlocklistService::blockIP(['ip' => $rRow['ip'], 'notes' => 'MYSQL BRUTEFORCE ATTACK']); + } + } + } - if ($rCurrentSize === $rLastSize) { - @unlink($this->rIdentifier); - return 0; - } + // Fast-path: skip expensive syslog tail/grep when file size has not changed. + $rSyslogMarker = CRONS_TMP_PATH . 'mysql_syslog_size'; + $rCurrentSize = @filesize('/var/log/syslog') ?: 0; + $rLastSize = file_exists($rSyslogMarker) ? intval(@file_get_contents($rSyslogMarker)) : -1; - @file_put_contents($rSyslogMarker, $rCurrentSize); - exec('sudo tail -n 1000 /var/log/syslog | grep mysqld', $rOutput, $rRetVal); - foreach ($rOutput as $rError) { - $rMySQLDParts = explode('mysqld[', $rError, 2); - if (count($rMySQLDParts) < 2) { - continue; - } + if ($rCurrentSize === $rLastSize) { + @unlink($this->rIdentifier); + return 0; + } - $rErrorParts = explode(']:', $rMySQLDParts[1], 2); - if (count($rErrorParts) < 2) { - continue; - } + @file_put_contents($rSyslogMarker, $rCurrentSize); + exec('sudo tail -n 1000 /var/log/syslog | grep mysqld', $rOutput, $rRetVal); + foreach ($rOutput as $rError) { + $rMySQLDParts = explode('mysqld[', $rError, 2); + if (count($rMySQLDParts) < 2) { + continue; + } - $rStrip = trim($rErrorParts[1]); - $rTime = strtotime(substr($rStrip, 0, 19)); + $rErrorParts = explode(']:', $rMySQLDParts[1], 2); + if (count($rErrorParts) < 2) { + continue; + } - if ($rMaxTime >= $rTime) { - continue; - } + $rStrip = trim($rErrorParts[1]); + $rTime = strtotime(substr($rStrip, 0, 19)); - if (empty($rStrip) || $this->inArray($rIgnoreErrors, $rStrip)) { - continue; - } + if ($rMaxTime >= $rTime) { + continue; + } - $rNote = null; - $rType = null; + if (empty($rStrip) || $this->inArray($rIgnoreErrors, $rStrip)) { + continue; + } - if (stripos($rStrip, '[Note]') !== false) { - $rNote = trim(explode('[Note]', $rStrip)[1]); - $rType = 'NOTICE'; - } elseif (stripos($rStrip, '[Warning]') !== false) { - $rNote = trim(explode('[Warning]', $rStrip)[1]); - $rType = 'WARNING'; - } elseif (stripos($rStrip, '[Error]') !== false) { - $rNote = trim(explode('[Error]', $rStrip)[1]); - $rType = 'ERROR'; - } + $rNote = null; + $rType = null; - if (!$rNote) { - continue; - } + if (stripos($rStrip, '[Note]') !== false) { + $rNote = trim(explode('[Note]', $rStrip)[1]); + $rType = 'NOTICE'; + } elseif (stripos($rStrip, '[Warning]') !== false) { + $rNote = trim(explode('[Warning]', $rStrip)[1]); + $rType = 'WARNING'; + } elseif (stripos($rStrip, '[Error]') !== false) { + $rNote = trim(explode('[Error]', $rStrip)[1]); + $rType = 'ERROR'; + } - $rUsername = null; - $rHost = null; - $rDatabase = null; + if (!$rNote) { + continue; + } - if (stripos($rNote, 'access denied for user') !== false) { - $rUsername = trim(explode("'", explode("user '", $rNote)[1])[0]); - $rHost = trim(explode("'", explode("user '", $rNote)[1])[2]); - $rType = 'AUTH'; - } + $rUsername = null; + $rHost = null; + $rDatabase = null; - if (stripos($rNote, 'user:') !== false) { - $rUsername = trim(explode("'", explode("user: '", $rNote)[1])[0]); - $rHost = trim(explode("'", explode("host: '", $rNote)[1])[0]); - $rDatabase = trim(explode("'", explode("db: '", $rNote)[1])[0]); - $rType = 'ABORTED'; - } + if (stripos($rNote, 'access denied for user') !== false) { + $rUsername = trim(explode("'", explode("user '", $rNote)[1])[0]); + $rHost = trim(explode("'", explode("user '", $rNote)[1])[2]); + $rType = 'AUTH'; + } - $db->query('INSERT INTO `mysql_syslog`(`type`,`error`,`username`,`ip`,`database`,`date`) VALUES(?,?,?,?,?,?)', $rType, $rNote, $rUsername, $rHost, $rDatabase, $rTime); - } + if (stripos($rNote, 'user:') !== false) { + $rUsername = trim(explode("'", explode("user: '", $rNote)[1])[0]); + $rHost = trim(explode("'", explode("host: '", $rNote)[1])[0]); + $rDatabase = trim(explode("'", explode("db: '", $rNote)[1])[0]); + $rType = 'ABORTED'; + } - @unlink($this->rIdentifier); + $db->query('INSERT INTO `mysql_syslog`(`type`,`error`,`username`,`ip`,`database`,`date`) VALUES(?,?,?,?,?,?)', $rType, $rNote, $rUsername, $rHost, $rDatabase, $rTime); + } - return 0; - } + @unlink($this->rIdentifier); - private function inArray(array $needles, string $haystack): bool { - foreach ($needles as $needle) { - if (stristr($haystack, $needle)) { - return true; - } - } - return false; - } + return 0; + } - private function checkMariaDB(): bool { - exec('systemctl is-active mariadb 2>/dev/null', $out, $code); - $isActive = isset($out[0]) && trim($out[0]) === 'active'; + private function inArray(array $needles, string $haystack): bool { + foreach ($needles as $needle) { + if (stristr($haystack, $needle)) { + return true; + } + } + return false; + } - if (!$isActive) { - echo "[MYSQL] MariaDB is DOWN, restarting...\n"; - exec('systemctl restart mariadb 2>&1', $restartOut, $restartCode); - sleep(3); + private function checkMariaDB(): bool { + exec('systemctl is-active mariadb 2>/dev/null', $out, $code); + $isActive = isset($out[0]) && trim($out[0]) === 'active'; - exec('systemctl is-active mariadb 2>/dev/null', $checkOut); - if (isset($checkOut[0]) && trim($checkOut[0]) === 'active') { - echo "[MYSQL] MariaDB successfully restarted\n"; - return true; - } else { - echo "[MYSQL] FAILED to restart MariaDB\n"; - return false; - } - } + if (!$isActive) { + echo "[MYSQL] MariaDB is DOWN, restarting...\n"; + exec('systemctl restart mariadb 2>&1', $restartOut, $restartCode); + sleep(3); - return true; - } + exec('systemctl is-active mariadb 2>/dev/null', $checkOut); + if (isset($checkOut[0]) && trim($checkOut[0]) === 'active') { + echo "[MYSQL] MariaDB successfully restarted\n"; + return true; + } else { + echo "[MYSQL] FAILED to restart MariaDB\n"; + return false; + } + } + + return true; + } } diff --git a/src/Cli/CronJobs/RootSignalsCronJob.php b/src/Cli/CronJobs/RootSignalsCronJob.php index 994432a9..60a6185b 100644 --- a/src/Cli/CronJobs/RootSignalsCronJob.php +++ b/src/Cli/CronJobs/RootSignalsCronJob.php @@ -20,713 +20,712 @@ use XcVm\Domain\Server\ServerRepository; */ class RootSignalsCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - private $rSaveIPTables = false; - private $AutoUpdateServerIP = true; + private $rSaveIPTables = false; - public function getName(): string { - return 'cron:root_signals'; - } + private $AutoUpdateServerIP = true; - public function getDescription(): string { - return 'Cron: process signals, iptables, nginx, service management (root)'; - } + public function getName(): string { + return 'cron:root_signals'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsRoot()) { - return 1; - } + public function getDescription(): string { + return 'Cron: process signals, iptables, nginx, service management (root)'; + } - set_time_limit(0); - register_shutdown_function([$this, 'shutdown']); + public function execute(array $rArgs): int { + if (!$this->assertRunAsRoot()) { + return 1; + } - $this->rIdentifier = CRONS_TMP_PATH . md5(Encryption::generateUniqueCode(SettingsManager::get('live_streaming_pass')) . static::class); - ProcessManager::acquireCronLock($this->rIdentifier); + set_time_limit(0); + register_shutdown_function([$this, 'shutdown']); - $pids = shell_exec("pgrep -f 'XC_VM\[Signals\]'"); - if (!empty($pids)) { - shell_exec("sudo kill -9 $pids"); - } - cli_set_process_title('XC_VM[Signals]'); - file_put_contents(CONFIG_PATH . 'signals.last', time()); + $this->rIdentifier = CRONS_TMP_PATH . md5(Encryption::generateUniqueCode(SettingsManager::get('live_streaming_pass')) . static::class); + ProcessManager::acquireCronLock($this->rIdentifier); - $this->loadCron(); + $pids = shell_exec("pgrep -f 'XC_VM\[Signals\]'"); + if (!empty($pids)) { + shell_exec("sudo kill -9 $pids"); + } + cli_set_process_title('XC_VM[Signals]'); + file_put_contents(CONFIG_PATH . 'signals.last', time()); - return 0; - } + $this->loadCron(); - private function blockip($rIP): bool { - $isPrivate = false; + return 0; + } - if (filter_var($rIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $isPrivate = filter_var($rIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); - $isPrivate = !$isPrivate; + private function blockip($rIP): bool { + $isPrivate = false; - if (!$isPrivate) { - $isPrivate = (strpos($rIP, '127.') === 0) || ($rIP === '0.0.0.0'); - } + if (filter_var($rIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $isPrivate = filter_var($rIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); + $isPrivate = !$isPrivate; - if (!$isPrivate) { - exec('sudo iptables -I INPUT -s ' . escapeshellcmd($rIP) . ' -j DROP'); - } - } elseif (filter_var($rIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - $isPrivate = ( - strpos($rIP, 'fc') === 0 || - strpos($rIP, 'fd') === 0 || - strpos($rIP, 'fe80') === 0 || - $rIP === '::1' || - strpos($rIP, '2001:db8') === 0 - ); + if (!$isPrivate) { + $isPrivate = (strpos($rIP, '127.') === 0) || ($rIP === '0.0.0.0'); + } - if (!$isPrivate) { - exec('sudo ip6tables -I INPUT -s ' . escapeshellcmd($rIP) . ' -j DROP'); - } - } + if (!$isPrivate) { + exec('sudo iptables -I INPUT -s ' . escapeshellcmd($rIP) . ' -j DROP'); + } + } elseif (filter_var($rIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $isPrivate = (strpos($rIP, 'fc') === 0 || + strpos($rIP, 'fd') === 0 || + strpos($rIP, 'fe80') === 0 || + $rIP === '::1' || + strpos($rIP, '2001:db8') === 0); - if (!$isPrivate && $rIP) { - touch(FLOOD_TMP_PATH . 'block_' . $rIP); - return true; - } elseif ($isPrivate) { - error_log("Block attempt denied for private IP: " . $rIP); - return false; - } + if (!$isPrivate) { + exec('sudo ip6tables -I INPUT -s ' . escapeshellcmd($rIP) . ' -j DROP'); + } + } - return false; - } + if (!$isPrivate && $rIP) { + touch(FLOOD_TMP_PATH . 'block_' . $rIP); + return true; + } elseif ($isPrivate) { + error_log("Block attempt denied for private IP: " . $rIP); + return false; + } - private function unblockip($rIP): void { - if (filter_var($rIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - exec('sudo iptables -D INPUT -s ' . escapeshellcmd($rIP) . ' -j DROP'); - } elseif (filter_var($rIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - exec('sudo ip6tables -D INPUT -s ' . escapeshellcmd($rIP) . ' -j DROP'); - } - if (file_exists(FLOOD_TMP_PATH . 'block_' . $rIP)) { - unlink(FLOOD_TMP_PATH . 'block_' . $rIP); - } - } + return false; + } - private function flushIPs(): void { - exec('sudo iptables -F && sudo ip6tables -F'); - shell_exec('sudo rm ' . FLOOD_TMP_PATH . 'block_*'); - } + private function unblockip($rIP): void { + if (filter_var($rIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + exec('sudo iptables -D INPUT -s ' . escapeshellcmd($rIP) . ' -j DROP'); + } elseif (filter_var($rIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + exec('sudo ip6tables -D INPUT -s ' . escapeshellcmd($rIP) . ' -j DROP'); + } + if (file_exists(FLOOD_TMP_PATH . 'block_' . $rIP)) { + unlink(FLOOD_TMP_PATH . 'block_' . $rIP); + } + } - private function saveiptables(): void { - exec('sudo iptables-save && sudo ip6tables-save'); - } + private function flushIPs(): void { + exec('sudo iptables -F && sudo ip6tables -F'); + shell_exec('sudo rm ' . FLOOD_TMP_PATH . 'block_*'); + } - private function getBlockedIPs(): array { - $rReturn = []; - exec('sudo iptables -nL --line-numbers -t filter', $rLines); - foreach ($rLines as $rLine) { - $rLine = explode(' ', preg_replace('!\\s+!', ' ', $rLine)); - if (isset($rLine[1], $rLine[4]) && $rLine[1] == 'DROP') { - $rReturn[] = $rLine[4]; - } - } - $rLines = ''; - exec('sudo ip6tables -nL --line-numbers -t filter', $rLines); - foreach ($rLines as $rLine) { - $rLine = explode(' ', preg_replace('!\\s+!', ' ', $rLine)); - if (isset($rLine[1], $rLine[3]) && $rLine[1] == 'DROP') { - $rReturn[] = $rLine[3]; - } - } - return $rReturn; - } + private function saveiptables(): void { + exec('sudo iptables-save && sudo ip6tables-save'); + } - private function getServerIP(?string $interface = null): ?string { - if ($interface === null) { - $route = shell_exec('ip route show default 2>/dev/null'); - if ($route && preg_match('/dev\s+([^\s]+)/', $route, $m)) { - $interface = $m[1]; - } else { - return null; - } - } + private function getBlockedIPs(): array { + $rReturn = []; + exec('sudo iptables -nL --line-numbers -t filter', $rLines); + foreach ($rLines as $rLine) { + $rLine = explode(' ', preg_replace('!\\s+!', ' ', $rLine)); + if (isset($rLine[1], $rLine[4]) && $rLine[1] == 'DROP') { + $rReturn[] = $rLine[4]; + } + } + $rLines = ''; + exec('sudo ip6tables -nL --line-numbers -t filter', $rLines); + foreach ($rLines as $rLine) { + $rLine = explode(' ', preg_replace('!\\s+!', ' ', $rLine)); + if (isset($rLine[1], $rLine[3]) && $rLine[1] == 'DROP') { + $rReturn[] = $rLine[3]; + } + } + return $rReturn; + } - $output = shell_exec( - 'ip -j addr show ' . escapeshellarg($interface) . ' 2>/dev/null' - ); + private function getServerIP(?string $interface = null): ?string { + if ($interface === null) { + $route = shell_exec('ip route show default 2>/dev/null'); + if ($route && preg_match('/dev\s+([^\s]+)/', $route, $m)) { + $interface = $m[1]; + } else { + return null; + } + } - if (!$output) { - return null; - } + $output = shell_exec( + 'ip -j addr show ' . escapeshellarg($interface) . ' 2>/dev/null' + ); - $data = json_decode($output, true); - if (empty($data[0]['addr_info'])) { - return null; - } + if (!$output) { + return null; + } - foreach ($data[0]['addr_info'] as $addr) { - if (($addr['family'] ?? null) === 'inet') { - return $addr['local'] ?? null; - } - } + $data = json_decode($output, true); + if (empty($data[0]['addr_info'])) { + return null; + } - return null; - } + foreach ($data[0]['addr_info'] as $addr) { + if (($addr['family'] ?? null) === 'inet') { + return $addr['local'] ?? null; + } + } - private function loadCron(): void { - global $db; - $rServers = ServerRepository::getAll(true); - $db->query("SELECT `signal_id` FROM `signals` WHERE `server_id` = ? AND `custom_data` = '{\"action\":\"flush\"}' AND `cache` = 0;", SERVER_ID); - if ($db->num_rows() > 0) { - echo "Flushing IP's..."; - $this->flushIPs(); - $this->saveiptables(); - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'FLUSH', 'Flushed blocked IP\\'s from iptables.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - $db->query("DELETE FROM `signals` WHERE `server_id` = ? AND `custom_data` = '{\"action\":\"flush\"}' AND `cache` = 0;", SERVER_ID); - } else { - // Auto-unban: on MAIN only, drop expired automatic IP bans (flood/ - // bruteforce) so the sync below removes them from iptables. Manual admin - // bans (any other notes) are left permanent. - $rUnbanSettings = SettingsManager::getAll(); - if (!empty($rServers[SERVER_ID]['is_main']) && !empty($rUnbanSettings['auto_unban_ip'])) { - $rUnbanMul = array('minutes' => 60, 'hours' => 3600, 'days' => 86400); - $rUnbanUnit = (string) ($rUnbanSettings['ban_duration_unit'] ?? 'hours'); - $rUnbanSecs = max(1, intval($rUnbanSettings['ban_duration_value'] ?? 24)) * ($rUnbanMul[$rUnbanUnit] ?? 3600); - $db->query("DELETE FROM `blocked_ips` WHERE `date` < ? AND (UPPER(`notes`) LIKE '%ATTACK%' OR UPPER(`notes`) LIKE '%BRUTEFORCE%' OR UPPER(`notes`) LIKE '%FLOOD%');", time() - $rUnbanSecs); - } + return null; + } - $rSyncMarker = CRONS_TMP_PATH . 'blocked_ips_sync_marker'; - $rRunFullSync = true; - $db->query('SELECT COUNT(*) AS `count` FROM `blocked_ips`;'); - $rCurrentIPCount = intval($db->get_row()['count']); + private function loadCron(): void { + global $db; + $rServers = ServerRepository::getAll(true); + $db->query("SELECT `signal_id` FROM `signals` WHERE `server_id` = ? AND `custom_data` = '{\"action\":\"flush\"}' AND `cache` = 0;", SERVER_ID); + if ($db->num_rows() > 0) { + echo "Flushing IP's..."; + $this->flushIPs(); + $this->saveiptables(); + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'FLUSH', 'Flushed blocked IP\\'s from iptables.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + $db->query("DELETE FROM `signals` WHERE `server_id` = ? AND `custom_data` = '{\"action\":\"flush\"}' AND `cache` = 0;", SERVER_ID); + } else { + // Auto-unban: on MAIN only, drop expired automatic IP bans (flood/ + // bruteforce) so the sync below removes them from iptables. Manual admin + // bans (any other notes) are left permanent. + $rUnbanSettings = SettingsManager::getAll(); + if (!empty($rServers[SERVER_ID]['is_main']) && !empty($rUnbanSettings['auto_unban_ip'])) { + $rUnbanMul = ['minutes' => 60, 'hours' => 3600, 'days' => 86400]; + $rUnbanUnit = (string) ($rUnbanSettings['ban_duration_unit'] ?? 'hours'); + $rUnbanSecs = max(1, intval($rUnbanSettings['ban_duration_value'] ?? 24)) * ($rUnbanMul[$rUnbanUnit] ?? 3600); + $db->query("DELETE FROM `blocked_ips` WHERE `date` < ? AND (UPPER(`notes`) LIKE '%ATTACK%' OR UPPER(`notes`) LIKE '%BRUTEFORCE%' OR UPPER(`notes`) LIKE '%FLOOD%');", time() - $rUnbanSecs); + } - if (file_exists($rSyncMarker)) { - $rLastSyncData = json_decode(@file_get_contents($rSyncMarker), true); - if (is_array($rLastSyncData) && isset($rLastSyncData['count'], $rLastSyncData['time'])) { - if (intval($rLastSyncData['count']) == $rCurrentIPCount && (time() - intval($rLastSyncData['time'])) < 300) { - $rRunFullSync = false; - } - } - } + $rSyncMarker = CRONS_TMP_PATH . 'blocked_ips_sync_marker'; + $rRunFullSync = true; + $db->query('SELECT COUNT(*) AS `count` FROM `blocked_ips`;'); + $rCurrentIPCount = intval($db->get_row()['count']); - if ($rRunFullSync) { - $rActualBlocked = $this->getBlockedIPs(); - $rActualBlockedFlip = array_flip($rActualBlocked); - $db->query('SELECT `ip` FROM `blocked_ips`;'); - $rBlocked = array_keys($db->get_rows(true, 'ip')); - $rBlockedFlip = array_flip($rBlocked); - $rAdd = $rDel = []; - foreach (array_count_values($rActualBlocked) as $rIP => $rCount) { - if ($rCount > 1) { - echo $rCount . "\n"; - foreach (range(1, $rCount - 1) as $i) { - $rDel[] = $rIP; - } - } - } - foreach ($rBlocked as $rIP) { - if (!isset($rActualBlockedFlip[$rIP])) { - $rAdd[] = $rIP; - } - } - foreach ($rActualBlocked as $rIP) { - if (!isset($rBlockedFlip[$rIP])) { - $rDel[] = $rIP; - } - } - if (count($rDel) > 0) { - $this->rSaveIPTables = true; - foreach ($rDel as $rIP) { - echo 'Unblock IP: ' . $rIP . "\n"; - $this->unblockip($rIP); - } - } - if (count($rAdd) > 0) { - $this->rSaveIPTables = true; - foreach ($rAdd as $rIP) { - echo 'Block IP: ' . $rIP . "\n"; - $this->blockip($rIP); - } - } - if ($this->rSaveIPTables) { - $this->saveiptables(); - $this->rSaveIPTables = false; - } - @file_put_contents($rSyncMarker, json_encode(['count' => $rCurrentIPCount, 'time' => time()])); - } - } - $rReload = false; - $rMinistraLegacyConf = 'set $ministra_legacy_redirect ' . (SettingsManager::get('mag_legacy_redirect') ? '1' : '0') . ';'; - $rCurrentMinistraLegacyConf = (trim(@file_get_contents(BIN_PATH . 'nginx/conf/ministra_legacy.conf')) ?: ''); - if ($rMinistraLegacyConf != $rCurrentMinistraLegacyConf) { - echo 'Updating Ministra legacy /c toggle...' . "\n"; - file_put_contents(BIN_PATH . 'nginx/conf/ministra_legacy.conf', $rMinistraLegacyConf); - $rReload = true; - } - $rAllowedIPs = ServerRepository::getAllowedIPs(); - $rXC_VMList = []; - foreach ($rAllowedIPs as $rIP) { - if (!empty($rIP) && filter_var($rIP, FILTER_VALIDATE_IP)) { - $newEntry = 'set_real_ip_from ' . $rIP . ';'; - if (!in_array($newEntry, $rXC_VMList)) { - $rXC_VMList[] = $newEntry; - } - } - } - $rXC_VMList = trim(implode("\n", array_unique($rXC_VMList))); - $rCurrentList = (trim(file_get_contents(BIN_PATH . 'nginx/conf/realip_xc_vm.conf')) ?: ''); - if ($rXC_VMList != $rCurrentList) { - echo 'Updating XC_VM IP List...' . "\n"; - file_put_contents(BIN_PATH . 'nginx/conf/realip_xc_vm.conf', $rXC_VMList); - $rReload = true; - } - $rCurrentList = (trim(file_get_contents(BIN_PATH . 'nginx/conf/realip_cloudflare.conf')) ?: ''); - if (SettingsManager::get('cloudflare')) { - if (empty($rCurrentList)) { - echo 'Enabling Cloudflare...' . "\n"; - file_put_contents(BIN_PATH . 'nginx/conf/realip_cloudflare.conf', 'set_real_ip_from 103.21.244.0/22;' . "\n" . 'set_real_ip_from 103.22.200.0/22;' . "\n" . 'set_real_ip_from 103.31.4.0/22;' . "\n" . 'set_real_ip_from 104.16.0.0/13;' . "\n" . 'set_real_ip_from 104.24.0.0/14;' . "\n" . 'set_real_ip_from 108.162.192.0/18;' . "\n" . 'set_real_ip_from 131.0.72.0/22;' . "\n" . 'set_real_ip_from 141.101.64.0/18;' . "\n" . 'set_real_ip_from 162.158.0.0/15;' . "\n" . 'set_real_ip_from 172.64.0.0/13;' . "\n" . 'set_real_ip_from 173.245.48.0/20;' . "\n" . 'set_real_ip_from 188.114.96.0/20;' . "\n" . 'set_real_ip_from 190.93.240.0/20;' . "\n" . 'set_real_ip_from 197.234.240.0/22;' . "\n" . 'set_real_ip_from 198.41.128.0/17;' . "\n" . 'set_real_ip_from 2400:cb00::/32;' . "\n" . 'set_real_ip_from 2606:4700::/32;' . "\n" . 'set_real_ip_from 2803:f800::/32;' . "\n" . 'set_real_ip_from 2405:b500::/32;' . "\n" . 'set_real_ip_from 2405:8100::/32;' . "\n" . 'set_real_ip_from 2c0f:f248::/32;' . "\n" . 'set_real_ip_from 2a06:98c0::/29;'); - $rReload = true; - } - } else { - if (!empty($rCurrentList)) { - echo 'Disabling Cloudflare...' . "\n"; - file_put_contents(BIN_PATH . 'nginx/conf/realip_cloudflare.conf', ''); - $rReload = true; - } - } - if ($rServers[SERVER_ID]['is_main']) { - $rCurrentStatus = stripos((trim(file_get_contents(BIN_PATH . 'nginx/conf/gzip.conf')) ?: 'gzip off'), 'gzip on') !== false; - if ($rServers[SERVER_ID]['enable_gzip']) { - if (!$rCurrentStatus) { - echo 'Enabling GZIP...' . "\n"; - file_put_contents(BIN_PATH . 'nginx/conf/gzip.conf', 'gzip on;' . "\n" . 'gzip_min_length 1000;' . "\n" . 'gzip_buffers 4 32k;' . "\n" . 'gzip_proxied any;' . "\n" . 'gzip_types application/json application/xml;' . "\n" . 'gzip_vary on;' . "\n" . 'gzip_disable "MSIE [1-6].(?!.*SV1)";'); - $rReload = true; - } - } else { - if ($rCurrentStatus) { - echo 'Disabling GZIP...' . "\n"; - file_put_contents(BIN_PATH . 'nginx/conf/gzip.conf', 'gzip off;'); - $rReload = true; - } - } + if (file_exists($rSyncMarker)) { + $rLastSyncData = json_decode(@file_get_contents($rSyncMarker), true); + if (is_array($rLastSyncData) && isset($rLastSyncData['count'], $rLastSyncData['time'])) { + if (intval($rLastSyncData['count']) == $rCurrentIPCount && (time() - intval($rLastSyncData['time'])) < 300) { + $rRunFullSync = false; + } + } + } - $rServerIP = $this->getServerIP(($rServers[SERVER_ID]['network_interface'] == 'auto' ? null : $rServers[SERVER_ID]['network_interface'])); - if ($rServerIP && $rServerIP != $rServers[SERVER_ID]['server_ip'] && $this->AutoUpdateServerIP) { - echo 'Updating server IP from ' . $rServers[SERVER_ID]['server_ip'] . ' to ' . $rServerIP . '...' . "\n"; - $db->query('UPDATE `servers` SET `server_ip` = ? WHERE `id` = ?;', $rServerIP, SERVER_ID); - $rServers[SERVER_ID]['server_ip'] = $rServerIP; - } + if ($rRunFullSync) { + $rActualBlocked = $this->getBlockedIPs(); + $rActualBlockedFlip = array_flip($rActualBlocked); + $db->query('SELECT `ip` FROM `blocked_ips`;'); + $rBlocked = array_keys($db->get_rows(true, 'ip')); + $rBlockedFlip = array_flip($rBlocked); + $rAdd = $rDel = []; + foreach (array_count_values($rActualBlocked) as $rIP => $rCount) { + if ($rCount > 1) { + echo $rCount . "\n"; + foreach (range(1, $rCount - 1) as $i) { + $rDel[] = $rIP; + } + } + } + foreach ($rBlocked as $rIP) { + if (!isset($rActualBlockedFlip[$rIP])) { + $rAdd[] = $rIP; + } + } + foreach ($rActualBlocked as $rIP) { + if (!isset($rBlockedFlip[$rIP])) { + $rDel[] = $rIP; + } + } + if (count($rDel) > 0) { + $this->rSaveIPTables = true; + foreach ($rDel as $rIP) { + echo 'Unblock IP: ' . $rIP . "\n"; + $this->unblockip($rIP); + } + } + if (count($rAdd) > 0) { + $this->rSaveIPTables = true; + foreach ($rAdd as $rIP) { + echo 'Block IP: ' . $rIP . "\n"; + $this->blockip($rIP); + } + } + if ($this->rSaveIPTables) { + $this->saveiptables(); + $this->rSaveIPTables = false; + } + @file_put_contents($rSyncMarker, json_encode(['count' => $rCurrentIPCount, 'time' => time()])); + } + } + $rReload = false; + $rMinistraLegacyConf = 'set $ministra_legacy_redirect ' . (SettingsManager::get('mag_legacy_redirect') ? '1' : '0') . ';'; + $rCurrentMinistraLegacyConf = (trim(@file_get_contents(BIN_PATH . 'nginx/conf/ministra_legacy.conf')) ?: ''); + if ($rMinistraLegacyConf != $rCurrentMinistraLegacyConf) { + echo 'Updating Ministra legacy /c toggle...' . "\n"; + file_put_contents(BIN_PATH . 'nginx/conf/ministra_legacy.conf', $rMinistraLegacyConf); + $rReload = true; + } + $rAllowedIPs = ServerRepository::getAllowedIPs(); + $rXC_VMList = []; + foreach ($rAllowedIPs as $rIP) { + if (!empty($rIP) && filter_var($rIP, FILTER_VALIDATE_IP)) { + $newEntry = 'set_real_ip_from ' . $rIP . ';'; + if (!in_array($newEntry, $rXC_VMList)) { + $rXC_VMList[] = $newEntry; + } + } + } + $rXC_VMList = trim(implode("\n", array_unique($rXC_VMList))); + $rCurrentList = (trim(file_get_contents(BIN_PATH . 'nginx/conf/realip_xc_vm.conf')) ?: ''); + if ($rXC_VMList != $rCurrentList) { + echo 'Updating XC_VM IP List...' . "\n"; + file_put_contents(BIN_PATH . 'nginx/conf/realip_xc_vm.conf', $rXC_VMList); + $rReload = true; + } + $rCurrentList = (trim(file_get_contents(BIN_PATH . 'nginx/conf/realip_cloudflare.conf')) ?: ''); + if (SettingsManager::get('cloudflare')) { + if (empty($rCurrentList)) { + echo 'Enabling Cloudflare...' . "\n"; + file_put_contents(BIN_PATH . 'nginx/conf/realip_cloudflare.conf', 'set_real_ip_from 103.21.244.0/22;' . "\n" . 'set_real_ip_from 103.22.200.0/22;' . "\n" . 'set_real_ip_from 103.31.4.0/22;' . "\n" . 'set_real_ip_from 104.16.0.0/13;' . "\n" . 'set_real_ip_from 104.24.0.0/14;' . "\n" . 'set_real_ip_from 108.162.192.0/18;' . "\n" . 'set_real_ip_from 131.0.72.0/22;' . "\n" . 'set_real_ip_from 141.101.64.0/18;' . "\n" . 'set_real_ip_from 162.158.0.0/15;' . "\n" . 'set_real_ip_from 172.64.0.0/13;' . "\n" . 'set_real_ip_from 173.245.48.0/20;' . "\n" . 'set_real_ip_from 188.114.96.0/20;' . "\n" . 'set_real_ip_from 190.93.240.0/20;' . "\n" . 'set_real_ip_from 197.234.240.0/22;' . "\n" . 'set_real_ip_from 198.41.128.0/17;' . "\n" . 'set_real_ip_from 2400:cb00::/32;' . "\n" . 'set_real_ip_from 2606:4700::/32;' . "\n" . 'set_real_ip_from 2803:f800::/32;' . "\n" . 'set_real_ip_from 2405:b500::/32;' . "\n" . 'set_real_ip_from 2405:8100::/32;' . "\n" . 'set_real_ip_from 2c0f:f248::/32;' . "\n" . 'set_real_ip_from 2a06:98c0::/29;'); + $rReload = true; + } + } else { + if (!empty($rCurrentList)) { + echo 'Disabling Cloudflare...' . "\n"; + file_put_contents(BIN_PATH . 'nginx/conf/realip_cloudflare.conf', ''); + $rReload = true; + } + } + if ($rServers[SERVER_ID]['is_main']) { + $rCurrentStatus = stripos((trim(file_get_contents(BIN_PATH . 'nginx/conf/gzip.conf')) ?: 'gzip off'), 'gzip on') !== false; + if ($rServers[SERVER_ID]['enable_gzip']) { + if (!$rCurrentStatus) { + echo 'Enabling GZIP...' . "\n"; + file_put_contents(BIN_PATH . 'nginx/conf/gzip.conf', 'gzip on;' . "\n" . 'gzip_min_length 1000;' . "\n" . 'gzip_buffers 4 32k;' . "\n" . 'gzip_proxied any;' . "\n" . 'gzip_types application/json application/xml;' . "\n" . 'gzip_vary on;' . "\n" . 'gzip_disable "MSIE [1-6].(?!.*SV1)";'); + $rReload = true; + } + } else { + if ($rCurrentStatus) { + echo 'Disabling GZIP...' . "\n"; + file_put_contents(BIN_PATH . 'nginx/conf/gzip.conf', 'gzip off;'); + $rReload = true; + } + } - if (empty(SettingsManager::get('live_streaming_pass'))) { - $db->query('UPDATE `settings` SET `live_streaming_pass` = ?', Encryption::randomString(40)); - } - } + $rServerIP = $this->getServerIP(($rServers[SERVER_ID]['network_interface'] == 'auto' ? null : $rServers[SERVER_ID]['network_interface'])); + if ($rServerIP && $rServerIP != $rServers[SERVER_ID]['server_ip'] && $this->AutoUpdateServerIP) { + echo 'Updating server IP from ' . $rServers[SERVER_ID]['server_ip'] . ' to ' . $rServerIP . '...' . "\n"; + $db->query('UPDATE `servers` SET `server_ip` = ? WHERE `id` = ?;', $rServerIP, SERVER_ID); + $rServers[SERVER_ID]['server_ip'] = $rServerIP; + } - // xc_fanout keepalive supervisor — ensure run.sh itself is alive. It is - // the daemon's Restart=always loop (respawns the daemon within ~2s of any - // exit, SIGKILL included), launched once by `service boot`. If IT dies - // (OOM, a stray kill) the daemon is left unsupervised and never comes back - // after its next exit — nothing else re-launches the loop (fanout_binary - // only pkills the daemon and relies on it; StartupCommand only fixes its - // mode). Re-launch it here every minute when absent — a cheap pgrep, - // idempotent regardless (run.sh holds an flock single-instance guard, so - // any racing duplicate supervisor exits at once), run as xc_vm to - // match `service boot`. Closes the "supervisor died → fanout stays down" - // gap. Runs on every node (main + LB), like the daemon self-heal below. - $rRunSh = MAIN_HOME . 'bin/xc_fanout/run.sh'; - if (is_file($rRunSh) && trim((string) shell_exec('pgrep -u xc_vm -f ' . escapeshellarg($rRunSh) . ' 2>/dev/null')) === '') { - shell_exec('sudo -u xc_vm bash ' . escapeshellarg($rRunSh) . ' >/dev/null 2>&1 &'); - } + if (empty(SettingsManager::get('live_streaming_pass'))) { + $db->query('UPDATE `settings` SET `live_streaming_pass` = ?', Encryption::randomString(40)); + } + } - // xc_fanout daemon binary — keep it installed and current (ADR 0003, - // Phase G). Nothing else pulls it: not the installer, not UpdateCommand, - // so a fresh node/LB would never get the daemon and an updated panel would - // keep an old one. fanout_binary is idempotent (downloads only on a - // version mismatch, and only when GitHub is reachable), so it is safe to - // poll. Throttle the check to ~hourly via a stamp, but the first pass - // (stamp absent) runs immediately so a fresh install/LB gets the daemon - // within a minute; the running daemon is respawned by fanout_binary on an - // actual upgrade. Root context (this cron) is required — it installs into - // bin/ and chowns. Runs on every node (main + LB) since LBs need it too. - $rFanoutStamp = CRONS_TMP_PATH . 'fanout_binary_check'; - if (!file_exists($rFanoutStamp) || time() - intval(@file_get_contents($rFanoutStamp) ?: 0) > 3600) { - file_put_contents($rFanoutStamp, time()); - shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php fanout_binary >/dev/null 2>&1 &'); - } + // xc_fanout keepalive supervisor — ensure run.sh itself is alive. It is + // the daemon's Restart=always loop (respawns the daemon within ~2s of any + // exit, SIGKILL included), launched once by `service boot`. If IT dies + // (OOM, a stray kill) the daemon is left unsupervised and never comes back + // after its next exit — nothing else re-launches the loop (fanout_binary + // only pkills the daemon and relies on it; StartupCommand only fixes its + // mode). Re-launch it here every minute when absent — a cheap pgrep, + // idempotent regardless (run.sh holds an flock single-instance guard, so + // any racing duplicate supervisor exits at once), run as xc_vm to + // match `service boot`. Closes the "supervisor died → fanout stays down" + // gap. Runs on every node (main + LB), like the daemon self-heal below. + $rRunSh = MAIN_HOME . 'bin/xc_fanout/run.sh'; + if (is_file($rRunSh) && trim((string) shell_exec('pgrep -u xc_vm -f ' . escapeshellarg($rRunSh) . ' 2>/dev/null')) === '') { + shell_exec('sudo -u xc_vm bash ' . escapeshellarg($rRunSh) . ' >/dev/null 2>&1 &'); + } - // xcvm_core PHP extension — same self-heal rationale as the daemon above. - // The extension is mirrored into the binaries repo tree decoupled from the - // heavy runtime bundle, so nothing else keeps it current: a fresh LB (or a - // node on an older extension) would never converge on its own. xcvm_core is - // idempotent (version-compared, downloads only on a mismatch) and installs - // with a load-test + rollback, so it is safe to poll ~hourly; the first - // pass runs immediately. This is what delivers config_set_redis to LB nodes, - // without which StatusCommand::configureRedisLb cannot point Redis at main. - $rCoreStamp = CRONS_TMP_PATH . 'xcvm_core_check'; - if (!file_exists($rCoreStamp) || time() - intval(@file_get_contents($rCoreStamp) ?: 0) > 3600) { - file_put_contents($rCoreStamp, time()); - shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php xcvm_core >/dev/null 2>&1 &'); - } + // xc_fanout daemon binary — keep it installed and current (ADR 0003, + // Phase G). Nothing else pulls it: not the installer, not UpdateCommand, + // so a fresh node/LB would never get the daemon and an updated panel would + // keep an old one. fanout_binary is idempotent (downloads only on a + // version mismatch, and only when GitHub is reachable), so it is safe to + // poll. Throttle the check to ~hourly via a stamp, but the first pass + // (stamp absent) runs immediately so a fresh install/LB gets the daemon + // within a minute; the running daemon is respawned by fanout_binary on an + // actual upgrade. Root context (this cron) is required — it installs into + // bin/ and chowns. Runs on every node (main + LB) since LBs need it too. + $rFanoutStamp = CRONS_TMP_PATH . 'fanout_binary_check'; + if (!file_exists($rFanoutStamp) || time() - intval(@file_get_contents($rFanoutStamp) ?: 0) > 3600) { + file_put_contents($rFanoutStamp, time()); + shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php fanout_binary >/dev/null 2>&1 &'); + } - // yt-dlp — same self-heal rationale. It is a static bundled binary that - // resolves media URLs (StreamUtils) and nothing else keeps it current, so - // it goes stale between panel releases and breaks extraction. The `ytdlp` - // command is idempotent (version-compared against the upstream release, - // downloads only on a mismatch, SHA-verified + run-tested before an atomic - // swap), so it is safe to poll. Daily is enough (yt-dlp releases ~weekly); - // the first pass (stamp absent) runs immediately. Runs on every node that - // has the binary (main + LB). - $rYtDlpStamp = CRONS_TMP_PATH . 'ytdlp_check'; - if (!file_exists($rYtDlpStamp) || time() - intval(@file_get_contents($rYtDlpStamp) ?: 0) > 86400) { - file_put_contents($rYtDlpStamp, time()); - shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php ytdlp >/dev/null 2>&1 &'); - } + // xcvm_core PHP extension — same self-heal rationale as the daemon above. + // The extension is mirrored into the binaries repo tree decoupled from the + // heavy runtime bundle, so nothing else keeps it current: a fresh LB (or a + // node on an older extension) would never converge on its own. xcvm_core is + // idempotent (version-compared, downloads only on a mismatch) and installs + // with a load-test + rollback, so it is safe to poll ~hourly; the first + // pass runs immediately. This is what delivers config_set_redis to LB nodes, + // without which StatusCommand::configureRedisLb cannot point Redis at main. + $rCoreStamp = CRONS_TMP_PATH . 'xcvm_core_check'; + if (!file_exists($rCoreStamp) || time() - intval(@file_get_contents($rCoreStamp) ?: 0) > 3600) { + file_put_contents($rCoreStamp, time()); + shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php xcvm_core >/dev/null 2>&1 &'); + } - if ($rServers[SERVER_ID]['limit_requests'] > 0) { - $rLimitConf = 'limit_req_zone global zone=two:10m rate=' . intval($rServers[SERVER_ID]['limit_requests']) . 'r/s;'; - } else { - $rLimitConf = ''; - } - $rCurrentConf = (trim(file_get_contents(BIN_PATH . 'nginx/conf/limit.conf')) ?: ''); - if ($rLimitConf != $rCurrentConf) { - echo 'Updating rate limit...' . "\n"; - file_put_contents(BIN_PATH . 'nginx/conf/limit.conf', $rLimitConf); - $rReload = true; - } - if ($rServers[SERVER_ID]['limit_requests'] > 0) { - $rLimitConf = 'limit_req zone=two burst=' . intval($rServers[SERVER_ID]['limit_burst']) . ';'; - } else { - $rLimitConf = ''; - } - $rCurrentConf = (trim(file_get_contents(BIN_PATH . 'nginx/conf/limit_queue.conf')) ?: ''); - if ($rLimitConf != $rCurrentConf) { - echo 'Updating rate limit queue...' . "\n"; - file_put_contents(BIN_PATH . 'nginx/conf/limit_queue.conf', $rLimitConf); - $rReload = true; - } - if ($rReload) { - shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); - } - if (SettingsManager::get('restart_php_fpm')) { - $rPHP = count(glob(BIN_PATH . 'php/sockets/*.pid') ?: []); - $rNginx = 0; - foreach (glob('/proc/*/cmdline') ?: [] as $rCmdFile) { - $rRaw = @file_get_contents($rCmdFile); - if ($rRaw && strpos(str_replace("\0", ' ', $rRaw), 'nginx: master') !== false) { - $rNginx++; - } - } - if ($rNginx > 0) { - if ($rPHP == 0) { - echo 'PHP-FPM ERROR - Restarting...'; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'PHP-FPM', 'Restarted PHP-FPM instances due to a suspected crash.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo systemctl stop xc_vm'); - shell_exec('sudo systemctl start xc_vm'); - exit(); - } - } - $rCurlMarker = CRONS_TMP_PATH . 'fpm_curl_check'; - if (!file_exists($rCurlMarker) || (time() - filemtime($rCurlMarker)) >= 300) { - @touch($rCurlMarker); - $rHandle = curl_init('http://127.0.0.1:' . $rServers[SERVER_ID]['http_broadcast_port'] . '/init'); - curl_setopt($rHandle, CURLOPT_RETURNTRANSFER, true); - curl_exec($rHandle); - $rCode = curl_getinfo($rHandle, CURLINFO_HTTP_CODE); - if (!in_array($rCode, [500, 502])) { - curl_close($rHandle); - } else { - echo $rCode . ' ERROR - Restarting...'; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'PHP-FPM', 'Restarted services due to " . $rCode . " error.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo systemctl stop xc_vm'); - shell_exec('sudo systemctl start xc_vm'); - exit(); - } - } - } - if ($db->query("SELECT `signal_id`, `custom_data` FROM `signals` WHERE `server_id` = ? AND `custom_data` <> '' AND `cache` = 0 ORDER BY signal_id ASC;", SERVER_ID)) { - $rRows = $db->get_rows(); - $rCheck = ['php' => false, 'services' => false, 'ports' => false, 'ramdisk' => false]; - foreach ($rRows as $rRow) { - $rData = json_decode($rRow['custom_data'], true); - switch ($rData['action']) { - case 'disable_ramdisk': - case 'enable_ramdisk': - $rCheck['ramdisk'] = true; - break; - case 'set_services': - $rCheck['services'] = true; - break; - case 'set_port': - $rCheck['ports'] = true; - break; - } - } - if ($rCheck['services']) { - $rCurServices = 0; - $rStartScript = explode("\n", file_get_contents(MAIN_HOME . 'bin/daemons.sh')); - foreach ($rStartScript as $rLine) { - if (explode(' ', $rLine)[0] == 'start-stop-daemon') { - $rCurServices++; - } - } - if ($rServers[SERVER_ID]['total_services'] != $rCurServices) { - array_unshift($rRows, ['custom_data' => json_encode(['action' => 'set_services', 'count' => $rServers[SERVER_ID]['total_services'], 'reload' => true])]); - } - } - if ($rCheck['ports']) { - $rListen = $rPorts = ['http' => [], 'https' => []]; - foreach (array_merge([intval($rServers[SERVER_ID]['http_broadcast_port'])], explode(',', $rServers[SERVER_ID]['http_ports_add'])) as $rPort) { - if (is_numeric($rPort) && $rPort > 0 && $rPort <= 65535) { - $rListen['http'][] = 'listen ' . intval($rPort) . ';'; - $rPorts['http'][] = intval($rPort); - } - } - foreach (array_merge([intval($rServers[SERVER_ID]['https_broadcast_port'])], explode(',', $rServers[SERVER_ID]['https_ports_add'])) as $rPort) { - if (is_numeric($rPort) && $rPort > 0 && $rPort <= 65535) { - $rListen['https'][] = 'listen ' . intval($rPort) . ' ssl;'; - $rPorts['https'][] = intval($rPort); - } - } - if (trim(implode(' ', $rListen['http'])) != trim(file_get_contents(MAIN_HOME . 'bin/nginx/conf/ports/http.conf'))) { - array_unshift($rRows, ['custom_data' => json_encode(['action' => 'set_port', 'type' => 0, 'ports' => $rPorts['http'], 'reload' => true])]); - } - if (trim(implode(' ', $rListen['https'])) != trim(file_get_contents(MAIN_HOME . 'bin/nginx/conf/ports/https.conf'))) { - array_unshift($rRows, ['custom_data' => json_encode(['action' => 'set_port', 'type' => 1, 'ports' => $rPorts['https'], 'reload' => true])]); - } - if ('listen ' . intval($rServers[SERVER_ID]['rtmp_port']) . ';' != trim(file_get_contents(MAIN_HOME . 'bin/nginx_rtmp/conf/port.conf'))) { - array_unshift($rRows, ['custom_data' => json_encode(['action' => 'set_port', 'type' => 2, 'ports' => [intval($rServers[SERVER_ID]['rtmp_port'])], 'reload' => true])]); - } - } - if ($rCheck['ramdisk']) { - $rMounted = false; - exec('df -h', $rLines); - array_shift($rLines); - foreach ($rLines as $rLine) { - $rSplit = explode(' ', preg_replace('!\\s+!', ' ', trim($rLine))); - if (implode(' ', array_slice($rSplit, 5, count($rSplit) - 5)) == rtrim(STREAMS_PATH, '/')) { - $rMounted = true; - break; - } - } - if ($rServers[SERVER_ID]['use_disk']) { - if ($rMounted) { - array_unshift($rRows, ['custom_data' => json_encode(['action' => 'disable_ramdisk'])]); - } - } else { - if (!$rMounted) { - array_unshift($rRows, ['custom_data' => json_encode(['action' => 'enable_ramdisk'])]); - } - } - } - if (file_exists(TMP_PATH . 'crontab')) { - echo 'Checking crontab...' . "\n"; - exec('crontab -u xc_vm -l', $rCrons); - $rCurrentCron = trim(implode("\n", $rCrons)); - $rJobs = []; - $db->query('SELECT * FROM `crontab` WHERE `enabled` = 1;'); - foreach ($db->get_rows() as $rRow) { - $rJobs[] = $rRow['time'] . ' ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:' . $rRow['filename'] . ' # XC_VM'; - } - $rActualCron = trim(implode("\n", $rJobs)); - if ($rCurrentCron != $rActualCron) { - echo 'Updating Crons...' . "\n"; - unlink(TMP_PATH . 'crontab'); - } else { - echo "Crons valid.\n"; - } - } - if (file_exists(CONFIG_PATH . 'sysctl.on')) { - if (strtoupper(substr(explode("\n", file_get_contents('/etc/sysctl.conf'))[0], 0, 7)) != '# XC_VM') { - echo 'Sysctl missing! Writing it.' . "\n"; - exec('sudo modprobe ip_conntrack'); - file_put_contents('/etc/sysctl.conf', implode(PHP_EOL, ['# XC_VM', '', 'net.core.somaxconn = 655350', 'net.ipv4.route.flush=1', 'net.ipv4.tcp_no_metrics_save=1', 'net.ipv4.tcp_moderate_rcvbuf = 1', 'fs.file-max = 6815744', 'fs.aio-max-nr = 6815744', 'fs.nr_open = 6815744', 'net.ipv4.ip_local_port_range = 1024 65000', 'net.ipv4.tcp_sack = 1', 'net.ipv4.tcp_rmem = 10000000 10000000 10000000', 'net.ipv4.tcp_wmem = 10000000 10000000 10000000', 'net.ipv4.tcp_mem = 10000000 10000000 10000000', 'net.core.rmem_max = 524287', 'net.core.wmem_max = 524287', 'net.core.rmem_default = 524287', 'net.core.wmem_default = 524287', 'net.core.optmem_max = 524287', 'net.core.netdev_max_backlog = 300000', 'net.ipv4.tcp_max_syn_backlog = 300000', 'net.netfilter.nf_conntrack_max=1215196608', 'net.ipv4.tcp_window_scaling = 1', 'vm.max_map_count = 655300', 'net.ipv4.tcp_max_tw_buckets = 50000', 'net.ipv6.conf.all.disable_ipv6 = 1', 'net.ipv6.conf.default.disable_ipv6 = 1', 'net.ipv6.conf.lo.disable_ipv6 = 1', 'kernel.shmmax=134217728', 'kernel.shmall=134217728', 'vm.overcommit_memory = 1', 'net.ipv4.tcp_tw_reuse=1'])); - exec('sudo sysctl -p > /dev/null'); - } - } - if (count($rRows) > 0) { - foreach ($rRows as $rRow) { - $rData = json_decode($rRow['custom_data'], true); - if (!empty($rRow['signal_id'])) { - $db->query('DELETE FROM `signals` WHERE `signal_id` = ?;', $rRow['signal_id']); - } - switch ($rData['action']) { - case 'reboot': - echo 'Rebooting system...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'REBOOT', 'System rebooted on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - $db->close_mysql(); - shell_exec('sudo reboot'); - break; - case 'restart_services': - echo 'Restarting services...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'RESTART', 'XC_VM services restarted on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo systemctl stop xc_vm'); - shell_exec('sudo systemctl start xc_vm'); - break; - case 'stop_services': - echo 'Stopping services...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'STOP', 'XC_VM services stopped on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo systemctl stop xc_vm'); - break; - case 'reload_nginx': - echo 'Reloading nginx...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'RELOAD', 'NGINX services reloaded on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . BIN_PATH . 'nginx_rtmp/sbin/nginx_rtmp -s reload'); - shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); - break; - case 'disable_ramdisk': - echo 'Disabling ramdisk...' . "\n"; - $rFstab = file_get_contents('/etc/fstab'); - $rOutput = []; - foreach (explode("\n", $rFstab) as $rLine) { - if (substr($rLine, 0, 31) == 'tmpfs /home/xc_vm/content/streams') { - $rLine = '#' . $rLine; - } - $rOutput[] = $rLine; - } - file_put_contents('/etc/fstab', implode("\n", $rOutput)); - shell_exec('sudo umount -l ' . STREAMS_PATH); - shell_exec('sudo chown -R xc_vm:xc_vm ' . STREAMS_PATH); - break; - case 'enable_ramdisk': - echo 'Enabling ramdisk...' . "\n"; - $rFstab = file_get_contents('/etc/fstab'); - $rOutput = []; - foreach (explode("\n", $rFstab) as $rLine) { - if (substr($rLine, 0, 32) == '#tmpfs /home/xc_vm/content/streams') { - $rLine = ltrim($rLine, '#'); - } - $rOutput[] = $rLine; - } - file_put_contents('/etc/fstab', implode("\n", $rOutput)); - shell_exec('sudo mount ' . STREAMS_PATH); - shell_exec('sudo chown -R xc_vm:xc_vm ' . STREAMS_PATH); - break; - case 'certbot_generate': - echo 'Generating certbot certificate.' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'CERTBOT', 'Attempting to generate certbot certificate on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php certbot "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); - break; - case 'update_binaries': - echo 'Updating binaries...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'BINARIES', 'Updating XC_VM binaries from XC_VM server...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php binaries 2>&1 &'); - break; - case 'install_module': - echo 'Installing module distributed from MAIN...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'MODULE', 'Installing module distributed from MAIN...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php module:install "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); - break; - case 'delete_module': - echo 'Deleting module removed on MAIN...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'MODULE', 'Deleting module removed on MAIN...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php module:delete "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); - break; - case 'update': - echo 'Updating...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'UPDATE', 'Updating XC_VM...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php update update 2>&1 &'); - break; - case 'rollback': - $rRbVersion = isset($rData['version']) ? trim((string) $rData['version']) : ''; - if (preg_match('/^\d+\.\d+\.\d+$/', $rRbVersion)) { - echo 'Rolling back to ' . $rRbVersion . '...' . "\n"; - $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'UPDATE', ?, 'root', 'localhost', NULL, ?);", SERVER_ID, 'Rolling back XC_VM to ' . $rRbVersion . '...', time()); - shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php update rollback ' . escapeshellarg($rRbVersion) . ' 2>&1 &'); - } - break; - case 'set_services': - echo 'Setting PHP Services' . "\n"; - $rServices = intval($rData['count']); - if ($rData['reload']) { - shell_exec('sudo systemctl stop xc_vm'); - } - shell_exec('sudo rm ' . MAIN_HOME . 'bin/php/etc/*.conf'); - $rNewScript = '#! /bin/bash' . "\n"; - $rNewBalance = 'upstream php {' . "\n" . ' least_conn;' . "\n"; - $rTemplate = file_get_contents(MAIN_HOME . 'bin/php/etc/template'); - foreach (range(1, $rServices) as $i) { - $rNewScript .= 'start-stop-daemon --start --quiet --pidfile ' . MAIN_HOME . 'bin/php/sockets/' . $i . '.pid --exec ' . MAIN_HOME . 'bin/php/sbin/php-fpm -- --daemonize --fpm-config ' . MAIN_HOME . 'bin/php/etc/' . $i . '.conf' . "\n"; - $rNewBalance .= ' server unix:' . MAIN_HOME . 'bin/php/sockets/' . $i . '.sock;' . "\n"; - file_put_contents(MAIN_HOME . 'bin/php/etc/' . $i . '.conf', str_replace('#PATH#', MAIN_HOME, str_replace('#ID#', (string) $i, $rTemplate))); - } - file_put_contents(MAIN_HOME . 'bin/daemons.sh', $rNewScript); - file_put_contents(MAIN_HOME . 'bin/nginx/conf/balance.conf', $rNewBalance . '}'); - shell_exec('sudo chown xc_vm:xc_vm ' . MAIN_HOME . 'bin/php/etc/*'); - if ($rData['reload']) { - shell_exec('sudo systemctl start xc_vm'); - } - break; - case 'set_governor': - $rNewGovernor = $rData['data']; - if (!empty($rNewGovernor) && shell_exec('which cpufreq-info')) { - $rGovernors = array_filter(explode(' ', trim(shell_exec('cpufreq-info -g')))); - $rGovernor = explode(' ', trim(shell_exec('cpufreq-info -p'))); - if ($rGovernor[2] != $rNewGovernor && in_array($rNewGovernor, $rGovernors)) { - shell_exec("sudo bash -c 'for ((i=0;i<\$(nproc);i++)); do cpufreq-set -c \$i -g " . $rNewGovernor . "; done'"); - sleep(2); - $rGovernor = explode(' ', trim(shell_exec('cpufreq-info -p'))); - $db->query('UPDATE `servers` SET `governor` = ? WHERE `id` = ?;', json_encode($rGovernor), SERVER_ID); - } - } - break; - case 'set_sysctl': - $rNewConfig = $rData['data']; - if (!empty($rNewConfig)) { - $rSysCtl = file_get_contents('/etc/sysctl.conf'); - if ($rSysCtl != $rNewConfig) { - shell_exec('sudo modprobe ip_conntrack > /dev/null'); - file_put_contents('/etc/sysctl.conf', $rNewConfig); - shell_exec('sudo sysctl -p > /dev/null'); - $db->query('UPDATE `servers` SET `sysctl` = ? WHERE `id` = ?;', $rNewConfig, SERVER_ID); - } - } - break; - case 'set_port': - echo 'Setting NGINX Port' . "\n"; - if (intval($rData['type']) == 0) { - $rListen = []; - foreach ($rData['ports'] as $rPort) { - if (is_numeric($rPort) && $rPort >= 80 && $rPort <= 65535) { - $rListen[] = 'listen ' . intval($rPort) . ';'; - } - } - file_put_contents(MAIN_HOME . 'bin/nginx/conf/ports/http.conf', implode(' ', $rListen)); - file_put_contents(MAIN_HOME . 'bin/nginx_rtmp/conf/live.conf', 'on_play http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp; on_publish http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp; on_play_done http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp;'); - if ($rData['reload']) { - shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); - } - } elseif (intval($rData['type']) == 1) { - $rListen = []; - foreach ($rData['ports'] as $rPort) { - if (is_numeric($rPort) && $rPort >= 80 && $rPort <= 65535) { - $rListen[] = 'listen ' . intval($rPort) . ' ssl;'; - } - } - file_put_contents(MAIN_HOME . 'bin/nginx/conf/ports/https.conf', implode(' ', $rListen)); - if ($rData['reload']) { - shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); - } - } elseif (intval($rData['type']) == 2) { - file_put_contents(MAIN_HOME . 'bin/nginx_rtmp/conf/port.conf', 'listen ' . intval($rData['ports'][0]) . ';'); - if ($rData['reload']) { - shell_exec('sudo ' . BIN_PATH . 'nginx_rtmp/sbin/nginx_rtmp -s reload'); - } - } - // no break - default: - break; - } - } - } - $db->query('DELETE FROM `signals` WHERE LENGTH(`custom_data`) > 0 AND UNIX_TIMESTAMP() - `time` >= 86400;'); - $db->close_mysql(); - } else { - exit(); - } - } + // yt-dlp — same self-heal rationale. It is a static bundled binary that + // resolves media URLs (StreamUtils) and nothing else keeps it current, so + // it goes stale between panel releases and breaks extraction. The `ytdlp` + // command is idempotent (version-compared against the upstream release, + // downloads only on a mismatch, SHA-verified + run-tested before an atomic + // swap), so it is safe to poll. Daily is enough (yt-dlp releases ~weekly); + // the first pass (stamp absent) runs immediately. Runs on every node that + // has the binary (main + LB). + $rYtDlpStamp = CRONS_TMP_PATH . 'ytdlp_check'; + if (!file_exists($rYtDlpStamp) || time() - intval(@file_get_contents($rYtDlpStamp) ?: 0) > 86400) { + file_put_contents($rYtDlpStamp, time()); + shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php ytdlp >/dev/null 2>&1 &'); + } - public function shutdown(): void { - global $db; - if ($this->rSaveIPTables) { - $this->saveiptables(); - } - if (is_object($db)) { - $db->close_mysql(); - } - @unlink($this->rIdentifier); - } + if ($rServers[SERVER_ID]['limit_requests'] > 0) { + $rLimitConf = 'limit_req_zone global zone=two:10m rate=' . intval($rServers[SERVER_ID]['limit_requests']) . 'r/s;'; + } else { + $rLimitConf = ''; + } + $rCurrentConf = (trim(file_get_contents(BIN_PATH . 'nginx/conf/limit.conf')) ?: ''); + if ($rLimitConf != $rCurrentConf) { + echo 'Updating rate limit...' . "\n"; + file_put_contents(BIN_PATH . 'nginx/conf/limit.conf', $rLimitConf); + $rReload = true; + } + if ($rServers[SERVER_ID]['limit_requests'] > 0) { + $rLimitConf = 'limit_req zone=two burst=' . intval($rServers[SERVER_ID]['limit_burst']) . ';'; + } else { + $rLimitConf = ''; + } + $rCurrentConf = (trim(file_get_contents(BIN_PATH . 'nginx/conf/limit_queue.conf')) ?: ''); + if ($rLimitConf != $rCurrentConf) { + echo 'Updating rate limit queue...' . "\n"; + file_put_contents(BIN_PATH . 'nginx/conf/limit_queue.conf', $rLimitConf); + $rReload = true; + } + if ($rReload) { + shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); + } + if (SettingsManager::get('restart_php_fpm')) { + $rPHP = count(glob(BIN_PATH . 'php/sockets/*.pid') ?: []); + $rNginx = 0; + foreach (glob('/proc/*/cmdline') ?: [] as $rCmdFile) { + $rRaw = @file_get_contents($rCmdFile); + if ($rRaw && strpos(str_replace("\0", ' ', $rRaw), 'nginx: master') !== false) { + $rNginx++; + } + } + if ($rNginx > 0) { + if ($rPHP == 0) { + echo 'PHP-FPM ERROR - Restarting...'; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'PHP-FPM', 'Restarted PHP-FPM instances due to a suspected crash.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo systemctl stop xc_vm'); + shell_exec('sudo systemctl start xc_vm'); + exit(); + } + } + $rCurlMarker = CRONS_TMP_PATH . 'fpm_curl_check'; + if (!file_exists($rCurlMarker) || (time() - filemtime($rCurlMarker)) >= 300) { + @touch($rCurlMarker); + $rHandle = curl_init('http://127.0.0.1:' . $rServers[SERVER_ID]['http_broadcast_port'] . '/init'); + curl_setopt($rHandle, CURLOPT_RETURNTRANSFER, true); + curl_exec($rHandle); + $rCode = curl_getinfo($rHandle, CURLINFO_HTTP_CODE); + if (!in_array($rCode, [500, 502])) { + curl_close($rHandle); + } else { + echo $rCode . ' ERROR - Restarting...'; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'PHP-FPM', 'Restarted services due to " . $rCode . " error.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo systemctl stop xc_vm'); + shell_exec('sudo systemctl start xc_vm'); + exit(); + } + } + } + if ($db->query("SELECT `signal_id`, `custom_data` FROM `signals` WHERE `server_id` = ? AND `custom_data` <> '' AND `cache` = 0 ORDER BY signal_id ASC;", SERVER_ID)) { + $rRows = $db->get_rows(); + $rCheck = ['php' => false, 'services' => false, 'ports' => false, 'ramdisk' => false]; + foreach ($rRows as $rRow) { + $rData = json_decode($rRow['custom_data'], true); + switch ($rData['action']) { + case 'disable_ramdisk': + case 'enable_ramdisk': + $rCheck['ramdisk'] = true; + break; + case 'set_services': + $rCheck['services'] = true; + break; + case 'set_port': + $rCheck['ports'] = true; + break; + } + } + if ($rCheck['services']) { + $rCurServices = 0; + $rStartScript = explode("\n", file_get_contents(MAIN_HOME . 'bin/daemons.sh')); + foreach ($rStartScript as $rLine) { + if (explode(' ', $rLine)[0] == 'start-stop-daemon') { + $rCurServices++; + } + } + if ($rServers[SERVER_ID]['total_services'] != $rCurServices) { + array_unshift($rRows, ['custom_data' => json_encode(['action' => 'set_services', 'count' => $rServers[SERVER_ID]['total_services'], 'reload' => true])]); + } + } + if ($rCheck['ports']) { + $rListen = $rPorts = ['http' => [], 'https' => []]; + foreach (array_merge([intval($rServers[SERVER_ID]['http_broadcast_port'])], explode(',', $rServers[SERVER_ID]['http_ports_add'])) as $rPort) { + if (is_numeric($rPort) && $rPort > 0 && $rPort <= 65535) { + $rListen['http'][] = 'listen ' . intval($rPort) . ';'; + $rPorts['http'][] = intval($rPort); + } + } + foreach (array_merge([intval($rServers[SERVER_ID]['https_broadcast_port'])], explode(',', $rServers[SERVER_ID]['https_ports_add'])) as $rPort) { + if (is_numeric($rPort) && $rPort > 0 && $rPort <= 65535) { + $rListen['https'][] = 'listen ' . intval($rPort) . ' ssl;'; + $rPorts['https'][] = intval($rPort); + } + } + if (trim(implode(' ', $rListen['http'])) != trim(file_get_contents(MAIN_HOME . 'bin/nginx/conf/ports/http.conf'))) { + array_unshift($rRows, ['custom_data' => json_encode(['action' => 'set_port', 'type' => 0, 'ports' => $rPorts['http'], 'reload' => true])]); + } + if (trim(implode(' ', $rListen['https'])) != trim(file_get_contents(MAIN_HOME . 'bin/nginx/conf/ports/https.conf'))) { + array_unshift($rRows, ['custom_data' => json_encode(['action' => 'set_port', 'type' => 1, 'ports' => $rPorts['https'], 'reload' => true])]); + } + if ('listen ' . intval($rServers[SERVER_ID]['rtmp_port']) . ';' != trim(file_get_contents(MAIN_HOME . 'bin/nginx_rtmp/conf/port.conf'))) { + array_unshift($rRows, ['custom_data' => json_encode(['action' => 'set_port', 'type' => 2, 'ports' => [intval($rServers[SERVER_ID]['rtmp_port'])], 'reload' => true])]); + } + } + if ($rCheck['ramdisk']) { + $rMounted = false; + exec('df -h', $rLines); + array_shift($rLines); + foreach ($rLines as $rLine) { + $rSplit = explode(' ', preg_replace('!\\s+!', ' ', trim($rLine))); + if (implode(' ', array_slice($rSplit, 5, count($rSplit) - 5)) == rtrim(STREAMS_PATH, '/')) { + $rMounted = true; + break; + } + } + if ($rServers[SERVER_ID]['use_disk']) { + if ($rMounted) { + array_unshift($rRows, ['custom_data' => json_encode(['action' => 'disable_ramdisk'])]); + } + } else { + if (!$rMounted) { + array_unshift($rRows, ['custom_data' => json_encode(['action' => 'enable_ramdisk'])]); + } + } + } + if (file_exists(TMP_PATH . 'crontab')) { + echo 'Checking crontab...' . "\n"; + exec('crontab -u xc_vm -l', $rCrons); + $rCurrentCron = trim(implode("\n", $rCrons)); + $rJobs = []; + $db->query('SELECT * FROM `crontab` WHERE `enabled` = 1;'); + foreach ($db->get_rows() as $rRow) { + $rJobs[] = $rRow['time'] . ' ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:' . $rRow['filename'] . ' # XC_VM'; + } + $rActualCron = trim(implode("\n", $rJobs)); + if ($rCurrentCron != $rActualCron) { + echo 'Updating Crons...' . "\n"; + unlink(TMP_PATH . 'crontab'); + } else { + echo "Crons valid.\n"; + } + } + if (file_exists(CONFIG_PATH . 'sysctl.on')) { + if (strtoupper(substr(explode("\n", file_get_contents('/etc/sysctl.conf'))[0], 0, 7)) != '# XC_VM') { + echo 'Sysctl missing! Writing it.' . "\n"; + exec('sudo modprobe ip_conntrack'); + file_put_contents('/etc/sysctl.conf', implode(PHP_EOL, ['# XC_VM', '', 'net.core.somaxconn = 655350', 'net.ipv4.route.flush=1', 'net.ipv4.tcp_no_metrics_save=1', 'net.ipv4.tcp_moderate_rcvbuf = 1', 'fs.file-max = 6815744', 'fs.aio-max-nr = 6815744', 'fs.nr_open = 6815744', 'net.ipv4.ip_local_port_range = 1024 65000', 'net.ipv4.tcp_sack = 1', 'net.ipv4.tcp_rmem = 10000000 10000000 10000000', 'net.ipv4.tcp_wmem = 10000000 10000000 10000000', 'net.ipv4.tcp_mem = 10000000 10000000 10000000', 'net.core.rmem_max = 524287', 'net.core.wmem_max = 524287', 'net.core.rmem_default = 524287', 'net.core.wmem_default = 524287', 'net.core.optmem_max = 524287', 'net.core.netdev_max_backlog = 300000', 'net.ipv4.tcp_max_syn_backlog = 300000', 'net.netfilter.nf_conntrack_max=1215196608', 'net.ipv4.tcp_window_scaling = 1', 'vm.max_map_count = 655300', 'net.ipv4.tcp_max_tw_buckets = 50000', 'net.ipv6.conf.all.disable_ipv6 = 1', 'net.ipv6.conf.default.disable_ipv6 = 1', 'net.ipv6.conf.lo.disable_ipv6 = 1', 'kernel.shmmax=134217728', 'kernel.shmall=134217728', 'vm.overcommit_memory = 1', 'net.ipv4.tcp_tw_reuse=1'])); + exec('sudo sysctl -p > /dev/null'); + } + } + if (count($rRows) > 0) { + foreach ($rRows as $rRow) { + $rData = json_decode($rRow['custom_data'], true); + if (!empty($rRow['signal_id'])) { + $db->query('DELETE FROM `signals` WHERE `signal_id` = ?;', $rRow['signal_id']); + } + switch ($rData['action']) { + case 'reboot': + echo 'Rebooting system...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'REBOOT', 'System rebooted on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + $db->close_mysql(); + shell_exec('sudo reboot'); + break; + case 'restart_services': + echo 'Restarting services...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'RESTART', 'XC_VM services restarted on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo systemctl stop xc_vm'); + shell_exec('sudo systemctl start xc_vm'); + break; + case 'stop_services': + echo 'Stopping services...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'STOP', 'XC_VM services stopped on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo systemctl stop xc_vm'); + break; + case 'reload_nginx': + echo 'Reloading nginx...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'RELOAD', 'NGINX services reloaded on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . BIN_PATH . 'nginx_rtmp/sbin/nginx_rtmp -s reload'); + shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); + break; + case 'disable_ramdisk': + echo 'Disabling ramdisk...' . "\n"; + $rFstab = file_get_contents('/etc/fstab'); + $rOutput = []; + foreach (explode("\n", $rFstab) as $rLine) { + if (substr($rLine, 0, 31) == 'tmpfs /home/xc_vm/content/streams') { + $rLine = '#' . $rLine; + } + $rOutput[] = $rLine; + } + file_put_contents('/etc/fstab', implode("\n", $rOutput)); + shell_exec('sudo umount -l ' . STREAMS_PATH); + shell_exec('sudo chown -R xc_vm:xc_vm ' . STREAMS_PATH); + break; + case 'enable_ramdisk': + echo 'Enabling ramdisk...' . "\n"; + $rFstab = file_get_contents('/etc/fstab'); + $rOutput = []; + foreach (explode("\n", $rFstab) as $rLine) { + if (substr($rLine, 0, 32) == '#tmpfs /home/xc_vm/content/streams') { + $rLine = ltrim($rLine, '#'); + } + $rOutput[] = $rLine; + } + file_put_contents('/etc/fstab', implode("\n", $rOutput)); + shell_exec('sudo mount ' . STREAMS_PATH); + shell_exec('sudo chown -R xc_vm:xc_vm ' . STREAMS_PATH); + break; + case 'certbot_generate': + echo 'Generating certbot certificate.' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'CERTBOT', 'Attempting to generate certbot certificate on request.', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php certbot "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); + break; + case 'update_binaries': + echo 'Updating binaries...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'BINARIES', 'Updating XC_VM binaries from XC_VM server...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php binaries 2>&1 &'); + break; + case 'install_module': + echo 'Installing module distributed from MAIN...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'MODULE', 'Installing module distributed from MAIN...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php module:install "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); + break; + case 'delete_module': + echo 'Deleting module removed on MAIN...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'MODULE', 'Deleting module removed on MAIN...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php module:delete "' . base64_encode(json_encode($rData)) . '" 2>&1 &'); + break; + case 'update': + echo 'Updating...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'UPDATE', 'Updating XC_VM...', 'root', 'localhost', NULL, ?);", SERVER_ID, time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php update update 2>&1 &'); + break; + case 'rollback': + $rRbVersion = isset($rData['version']) ? trim((string) $rData['version']) : ''; + if (preg_match('/^\d+\.\d+\.\d+$/', $rRbVersion)) { + echo 'Rolling back to ' . $rRbVersion . '...' . "\n"; + $db->query("INSERT INTO `mysql_syslog`(`server_id`, `type`, `error`, `username`, `ip`, `database`, `date`) VALUES(?, 'UPDATE', ?, 'root', 'localhost', NULL, ?);", SERVER_ID, 'Rolling back XC_VM to ' . $rRbVersion . '...', time()); + shell_exec('sudo ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php update rollback ' . escapeshellarg($rRbVersion) . ' 2>&1 &'); + } + break; + case 'set_services': + echo 'Setting PHP Services' . "\n"; + $rServices = intval($rData['count']); + if ($rData['reload']) { + shell_exec('sudo systemctl stop xc_vm'); + } + shell_exec('sudo rm ' . MAIN_HOME . 'bin/php/etc/*.conf'); + $rNewScript = '#! /bin/bash' . "\n"; + $rNewBalance = 'upstream php {' . "\n" . ' least_conn;' . "\n"; + $rTemplate = file_get_contents(MAIN_HOME . 'bin/php/etc/template'); + foreach (range(1, $rServices) as $i) { + $rNewScript .= 'start-stop-daemon --start --quiet --pidfile ' . MAIN_HOME . 'bin/php/sockets/' . $i . '.pid --exec ' . MAIN_HOME . 'bin/php/sbin/php-fpm -- --daemonize --fpm-config ' . MAIN_HOME . 'bin/php/etc/' . $i . '.conf' . "\n"; + $rNewBalance .= ' server unix:' . MAIN_HOME . 'bin/php/sockets/' . $i . '.sock;' . "\n"; + file_put_contents(MAIN_HOME . 'bin/php/etc/' . $i . '.conf', str_replace('#PATH#', MAIN_HOME, str_replace('#ID#', (string) $i, $rTemplate))); + } + file_put_contents(MAIN_HOME . 'bin/daemons.sh', $rNewScript); + file_put_contents(MAIN_HOME . 'bin/nginx/conf/balance.conf', $rNewBalance . '}'); + shell_exec('sudo chown xc_vm:xc_vm ' . MAIN_HOME . 'bin/php/etc/*'); + if ($rData['reload']) { + shell_exec('sudo systemctl start xc_vm'); + } + break; + case 'set_governor': + $rNewGovernor = $rData['data']; + if (!empty($rNewGovernor) && shell_exec('which cpufreq-info')) { + $rGovernors = array_filter(explode(' ', trim(shell_exec('cpufreq-info -g')))); + $rGovernor = explode(' ', trim(shell_exec('cpufreq-info -p'))); + if ($rGovernor[2] != $rNewGovernor && in_array($rNewGovernor, $rGovernors)) { + shell_exec("sudo bash -c 'for ((i=0;i<\$(nproc);i++)); do cpufreq-set -c \$i -g " . $rNewGovernor . "; done'"); + sleep(2); + $rGovernor = explode(' ', trim(shell_exec('cpufreq-info -p'))); + $db->query('UPDATE `servers` SET `governor` = ? WHERE `id` = ?;', json_encode($rGovernor), SERVER_ID); + } + } + break; + case 'set_sysctl': + $rNewConfig = $rData['data']; + if (!empty($rNewConfig)) { + $rSysCtl = file_get_contents('/etc/sysctl.conf'); + if ($rSysCtl != $rNewConfig) { + shell_exec('sudo modprobe ip_conntrack > /dev/null'); + file_put_contents('/etc/sysctl.conf', $rNewConfig); + shell_exec('sudo sysctl -p > /dev/null'); + $db->query('UPDATE `servers` SET `sysctl` = ? WHERE `id` = ?;', $rNewConfig, SERVER_ID); + } + } + break; + case 'set_port': + echo 'Setting NGINX Port' . "\n"; + if (intval($rData['type']) == 0) { + $rListen = []; + foreach ($rData['ports'] as $rPort) { + if (is_numeric($rPort) && $rPort >= 80 && $rPort <= 65535) { + $rListen[] = 'listen ' . intval($rPort) . ';'; + } + } + file_put_contents(MAIN_HOME . 'bin/nginx/conf/ports/http.conf', implode(' ', $rListen)); + file_put_contents(MAIN_HOME . 'bin/nginx_rtmp/conf/live.conf', 'on_play http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp; on_publish http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp; on_play_done http://127.0.0.1:' . intval($rData['ports'][0]) . '/stream/rtmp;'); + if ($rData['reload']) { + shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); + } + } elseif (intval($rData['type']) == 1) { + $rListen = []; + foreach ($rData['ports'] as $rPort) { + if (is_numeric($rPort) && $rPort >= 80 && $rPort <= 65535) { + $rListen[] = 'listen ' . intval($rPort) . ' ssl;'; + } + } + file_put_contents(MAIN_HOME . 'bin/nginx/conf/ports/https.conf', implode(' ', $rListen)); + if ($rData['reload']) { + shell_exec('sudo ' . BIN_PATH . 'nginx/sbin/nginx -s reload'); + } + } elseif (intval($rData['type']) == 2) { + file_put_contents(MAIN_HOME . 'bin/nginx_rtmp/conf/port.conf', 'listen ' . intval($rData['ports'][0]) . ';'); + if ($rData['reload']) { + shell_exec('sudo ' . BIN_PATH . 'nginx_rtmp/sbin/nginx_rtmp -s reload'); + } + } + // no break + default: + break; + } + } + } + $db->query('DELETE FROM `signals` WHERE LENGTH(`custom_data`) > 0 AND UNIX_TIMESTAMP() - `time` >= 86400;'); + $db->close_mysql(); + } else { + exit(); + } + } + + public function shutdown(): void { + global $db; + if ($this->rSaveIPTables) { + $this->saveiptables(); + } + if (is_object($db)) { + $db->close_mysql(); + } + @unlink($this->rIdentifier); + } } diff --git a/src/Cli/CronJobs/SeriesCronJob.php b/src/Cli/CronJobs/SeriesCronJob.php index bc31fe3d..744653a6 100644 --- a/src/Cli/CronJobs/SeriesCronJob.php +++ b/src/Cli/CronJobs/SeriesCronJob.php @@ -19,60 +19,60 @@ use XcVm\Domain\Vod\SeriesService; */ class SeriesCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:series'; - } + public function getName(): string { + return 'cron:series'; + } - public function getDescription(): string { - return 'Cron: update series playlists and scan bouquets'; - } + public function getDescription(): string { + return 'Cron: update series playlists and scan bouquets'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->setProcessTitle('XC_VM[Series]'); - $this->acquireCronLock(); + $this->setProcessTitle('XC_VM[Series]'); + $this->acquireCronLock(); - $this->loadCron(); + $this->loadCron(); - @unlink($this->rIdentifier); + @unlink($this->rIdentifier); - return 0; - } + return 0; + } - private function loadCron(): void { - global $db; + private function loadCron(): void { + global $db; - if (time() - SettingsManager::get('cc_time') < 3600) { - return; - } + if (time() - SettingsManager::get('cc_time') < 3600) { + return; + } - $db->query('UPDATE `settings` SET `cc_time` = ?;', time()); - $db->query('SELECT `id`, `stream_display_name`, `series_no`, `stream_source` FROM `streams` WHERE `type` = 3 AND `series_no` <> 0;'); + $db->query('UPDATE `settings` SET `cc_time` = ?;', time()); + $db->query('SELECT `id`, `stream_display_name`, `series_no`, `stream_source` FROM `streams` WHERE `type` = 3 AND `series_no` <> 0;'); - if ($db->num_rows() > 0) { - foreach ($db->get_rows() as $rRow) { - $rPlaylist = SeriesService::generatePlaylist(intval($rRow['series_no'])); - if ($rPlaylist['success']) { - $rSourceArray = json_decode($rRow['stream_source'], true); - $UpdateSeries = false; - foreach ($rPlaylist['sources'] as $rSource) { - if (!in_array($rSource, $rSourceArray)) { - $UpdateSeries = true; - } - } - if ($UpdateSeries) { - $db->query('UPDATE `streams` SET `stream_source` = ? WHERE `id` = ?;', json_encode($rPlaylist['sources'], JSON_UNESCAPED_UNICODE), $rRow['id']); - echo 'Updated: ' . $rRow['stream_display_name'] . "\n"; - } - } - } - } + if ($db->num_rows() > 0) { + foreach ($db->get_rows() as $rRow) { + $rPlaylist = SeriesService::generatePlaylist(intval($rRow['series_no'])); + if ($rPlaylist['success']) { + $rSourceArray = json_decode($rRow['stream_source'], true); + $UpdateSeries = false; + foreach ($rPlaylist['sources'] as $rSource) { + if (!in_array($rSource, $rSourceArray)) { + $UpdateSeries = true; + } + } + if ($UpdateSeries) { + $db->query('UPDATE `streams` SET `stream_source` = ? WHERE `id` = ?;', json_encode($rPlaylist['sources'], JSON_UNESCAPED_UNICODE), $rRow['id']); + echo 'Updated: ' . $rRow['stream_display_name'] . "\n"; + } + } + } + } - BouquetService::scan(); - } + BouquetService::scan(); + } } diff --git a/src/Cli/CronJobs/ServersCronJob.php b/src/Cli/CronJobs/ServersCronJob.php index 316938c1..74d11796 100644 --- a/src/Cli/CronJobs/ServersCronJob.php +++ b/src/Cli/CronJobs/ServersCronJob.php @@ -21,204 +21,204 @@ use XcVm\Domain\Server\ServerRepository; */ class ServersCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:servers'; - } + public function getName(): string { + return 'cron:servers'; + } - public function getDescription(): string { - return 'Cron: monitor server, launch daemons, update statistics'; - } + public function getDescription(): string { + return 'Cron: monitor server, launch daemons, update statistics'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->initCron('XC_VM[Servers]'); - $this->loadCron(); + $this->initCron('XC_VM[Servers]'); + $this->loadCron(); - return 0; - } + return 0; + } - private function pingServer(string $rIP, $rPort): int { - $rStartTime = microtime(true); - $rSocket = @fsockopen($rIP, $rPort, $rErrNo, $rErrStr, 3); - $rStopTime = microtime(true); - if (!$rSocket) { - $rStatus = -1; - } else { - fclose($rSocket); - $rStatus = floor(($rStopTime - $rStartTime) * 1000); - } - return $rStatus; - } + private function pingServer(string $rIP, $rPort): int { + $rStartTime = microtime(true); + $rSocket = @fsockopen($rIP, $rPort, $rErrNo, $rErrStr, 3); + $rStopTime = microtime(true); + if (!$rSocket) { + $rStatus = -1; + } else { + fclose($rSocket); + $rStatus = floor(($rStopTime - $rStartTime) * 1000); + } + return $rStatus; + } - private function loadCron(): void { - global $db; + private function loadCron(): void { + global $db; - SettingsManager::set(SettingsRepository::getAll(true)); + SettingsManager::set(SettingsRepository::getAll(true)); - if (!ProcessManager::isNginxRunning()) { - echo 'XC_VM not running...' . "\n"; - return; - } + if (!ProcessManager::isNginxRunning()) { + echo 'XC_VM not running...' . "\n"; + return; + } - $rServers = ServerRepository::getAll(true); + $rServers = ServerRepository::getAll(true); - // The current server must be present in the map; if it isn't (row not - // yet inserted, or a transient load failure) every $rServers[SERVER_ID] - // access below would raise offset-on-null warnings and do nothing useful. - if (!isset($rServers[SERVER_ID])) { - echo 'Server ' . SERVER_ID . ' not found in servers list...' . "\n"; - return; - } + // The current server must be present in the map; if it isn't (row not + // yet inserted, or a transient load failure) every $rServers[SERVER_ID] + // access below would raise offset-on-null warnings and do nothing useful. + if (!isset($rServers[SERVER_ID])) { + echo 'Server ' . SERVER_ID . ' not found in servers list...' . "\n"; + return; + } - if ($rServers[SERVER_ID]['is_main'] && SettingsManager::get('redis_handler')) { - exec('pgrep -u xc_vm redis-server', $rRedis); - if (count($rRedis) == 0) { - echo 'Restarting Redis!' . "\n"; - shell_exec(MAIN_HOME . 'bin/redis/redis-server ' . MAIN_HOME . '/bin/redis/redis.conf > /dev/null 2>/dev/null &'); - } - } + if ($rServers[SERVER_ID]['is_main'] && SettingsManager::get('redis_handler')) { + exec('pgrep -u xc_vm redis-server', $rRedis); + if (count($rRedis) == 0) { + echo 'Restarting Redis!' . "\n"; + shell_exec(MAIN_HOME . 'bin/redis/redis-server ' . MAIN_HOME . '/bin/redis/redis.conf > /dev/null 2>/dev/null &'); + } + } - // Daemon liveness checks read /proc via ProcessManager: the old - // "ps | grep " pipelines matched unrelated processes — e.g. - // ffmpeg's -thread_queue_size satisfied the "queue" check, so the - // encode queue daemon was never revived while any stream was up. - if (!ProcessManager::isAnyProcessRunning(array('XC_VM[Signals]', 'console.php signals'))) { - shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php signals > /dev/null 2>/dev/null &'); - } + // Daemon liveness checks read /proc via ProcessManager: the old + // "ps | grep " pipelines matched unrelated processes — e.g. + // ffmpeg's -thread_queue_size satisfied the "queue" check, so the + // encode queue daemon was never revived while any stream was up. + if (!ProcessManager::isAnyProcessRunning(['XC_VM[Signals]', 'console.php signals'])) { + shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php signals > /dev/null 2>/dev/null &'); + } - if ($rServers[SERVER_ID]['is_main']) { - $rCachePIDs = ProcessManager::findProcessPIDs(array('XC_VM[CacheHandler]', 'console.php cache_handler')); - if (SettingsManager::get('enable_cache') && count($rCachePIDs) == 0) { - shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php cache_handler > /dev/null 2>/dev/null &'); - } elseif (!SettingsManager::get('enable_cache') && count($rCachePIDs) > 0) { - echo 'Killing Cache Handler' . "\n"; - foreach ($rCachePIDs as $rPID) { - shell_exec('kill -9 ' . intval($rPID)); - } - } - } + if ($rServers[SERVER_ID]['is_main']) { + $rCachePIDs = ProcessManager::findProcessPIDs(['XC_VM[CacheHandler]', 'console.php cache_handler']); + if (SettingsManager::get('enable_cache') && count($rCachePIDs) == 0) { + shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php cache_handler > /dev/null 2>/dev/null &'); + } elseif (!SettingsManager::get('enable_cache') && count($rCachePIDs) > 0) { + echo 'Killing Cache Handler' . "\n"; + foreach ($rCachePIDs as $rPID) { + shell_exec('kill -9 ' . intval($rPID)); + } + } + } - if (!ProcessManager::isAnyProcessRunning(array(BIN_PATH . 'network'))) { - shell_exec(BIN_PATH . 'network > /dev/null 2>/dev/null &'); - } + if (!ProcessManager::isAnyProcessRunning([BIN_PATH . 'network'])) { + shell_exec(BIN_PATH . 'network > /dev/null 2>/dev/null &'); + } - // A watchdog generation lives only a few seconds. If one is still - // present but far older it is wedged — typically blocked in poll() - // on a half-open MariaDB socket (CLOSE_WAIT) inside its DB ping — and - // will never refresh last_check_ago, so the panel marks the node - // offline while nginx/php/redis/streams are all fine. Kill the stale - // process and start a fresh generation; a plain presence check alone - // would keep trusting the wedged one forever. - $rWatchdogAlive = false; - foreach (ProcessManager::findProcessPIDs(array('XC_VM[Watchdog]', 'console.php watchdog')) as $rWatchdogPID) { - if (ProcessManager::getProcessAge($rWatchdogPID) > 90) { - ProcessManager::kill($rWatchdogPID); - continue; - } - $rWatchdogAlive = true; - } - if (!$rWatchdogAlive) { - shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php watchdog > /dev/null 2>/dev/null &'); - } + // A watchdog generation lives only a few seconds. If one is still + // present but far older it is wedged — typically blocked in poll() + // on a half-open MariaDB socket (CLOSE_WAIT) inside its DB ping — and + // will never refresh last_check_ago, so the panel marks the node + // offline while nginx/php/redis/streams are all fine. Kill the stale + // process and start a fresh generation; a plain presence check alone + // would keep trusting the wedged one forever. + $rWatchdogAlive = false; + foreach (ProcessManager::findProcessPIDs(['XC_VM[Watchdog]', 'console.php watchdog']) as $rWatchdogPID) { + if (ProcessManager::getProcessAge($rWatchdogPID) > 90) { + ProcessManager::kill($rWatchdogPID); + continue; + } + $rWatchdogAlive = true; + } + if (!$rWatchdogAlive) { + shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php watchdog > /dev/null 2>/dev/null &'); + } - if (!ProcessManager::isAnyProcessRunning(array('XC_VM[Queue]', 'console.php queue'))) { - shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php queue > /dev/null 2>/dev/null &'); - } + if (!ProcessManager::isAnyProcessRunning(['XC_VM[Queue]', 'console.php queue'])) { + shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php queue > /dev/null 2>/dev/null &'); + } - $rOnDemandPIDs = ProcessManager::findProcessPIDs(array('XC_VM[Ondemand]', 'console.php ondemand')); - if (SettingsManager::get('on_demand_instant_off') && count($rOnDemandPIDs) == 0) { - shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php ondemand > /dev/null 2>/dev/null &'); - } elseif (!SettingsManager::get('on_demand_instant_off') && count($rOnDemandPIDs) > 0) { - echo 'Killing On-Demand Instant-Off' . "\n"; - foreach ($rOnDemandPIDs as $rPID) { - shell_exec('kill -9 ' . intval($rPID)); - } - } + $rOnDemandPIDs = ProcessManager::findProcessPIDs(['XC_VM[Ondemand]', 'console.php ondemand']); + if (SettingsManager::get('on_demand_instant_off') && count($rOnDemandPIDs) == 0) { + shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php ondemand > /dev/null 2>/dev/null &'); + } elseif (!SettingsManager::get('on_demand_instant_off') && count($rOnDemandPIDs) > 0) { + echo 'Killing On-Demand Instant-Off' . "\n"; + foreach ($rOnDemandPIDs as $rPID) { + shell_exec('kill -9 ' . intval($rPID)); + } + } - $rScannerPIDs = ProcessManager::findProcessPIDs(array('XC_VM[Scanner]', 'console.php scanner')); - if (SettingsManager::get('on_demand_checker') && count($rScannerPIDs) == 0) { - shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php scanner > /dev/null 2>/dev/null &'); - } elseif (!SettingsManager::get('on_demand_checker') && count($rScannerPIDs) > 0) { - echo 'Killing On-Demand Scanner' . "\n"; - foreach ($rScannerPIDs as $rPID) { - shell_exec('kill -9 ' . intval($rPID)); - } - } + $rScannerPIDs = ProcessManager::findProcessPIDs(['XC_VM[Scanner]', 'console.php scanner']); + if (SettingsManager::get('on_demand_checker') && count($rScannerPIDs) == 0) { + shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php scanner > /dev/null 2>/dev/null &'); + } elseif (!SettingsManager::get('on_demand_checker') && count($rScannerPIDs) > 0) { + echo 'Killing On-Demand Scanner' . "\n"; + foreach ($rScannerPIDs as $rPID) { + shell_exec('kill -9 ' . intval($rPID)); + } + } - $rStats = SystemInfo::getStats(); - $rWatchdog = json_decode($rServers[SERVER_ID]['watchdog_data'] ?? '', true); - $rCPUAverage = ($rWatchdog['cpu_average_array'] ?? []) ?: []; - if (count($rCPUAverage) > 0) { - $rStats['cpu'] = round(array_sum($rCPUAverage) / count($rCPUAverage), 2); - } + $rStats = SystemInfo::getStats(); + $rWatchdog = json_decode($rServers[SERVER_ID]['watchdog_data'] ?? '', true); + $rCPUAverage = ($rWatchdog['cpu_average_array'] ?? []) ?: []; + if (count($rCPUAverage) > 0) { + $rStats['cpu'] = round(array_sum($rCPUAverage) / count($rCPUAverage), 2); + } - $rHardware = array('total_ram' => $rStats['total_mem'], 'total_used' => $rStats['total_mem_used'], 'cores' => $rStats['cpu_cores'], 'threads' => $rStats['cpu_cores'], 'kernel' => $rStats['kernel'], 'total_running_streams' => $rStats['total_running_streams'], 'cpu_name' => $rStats['cpu_name'], 'cpu_usage' => $rStats['cpu'], 'network_speed' => $rStats['network_speed'], 'bytes_sent' => $rStats['bytes_sent'], 'bytes_received' => $rStats['bytes_received']); + $rHardware = ['total_ram' => $rStats['total_mem'], 'total_used' => $rStats['total_mem_used'], 'cores' => $rStats['cpu_cores'], 'threads' => $rStats['cpu_cores'], 'kernel' => $rStats['kernel'], 'total_running_streams' => $rStats['total_running_streams'], 'cpu_name' => $rStats['cpu_name'], 'cpu_usage' => $rStats['cpu'], 'network_speed' => $rStats['network_speed'], 'bytes_sent' => $rStats['bytes_sent'], 'bytes_received' => $rStats['bytes_received']]; - if (@fsockopen($rServers[SERVER_ID]['server_ip'], $rServers[SERVER_ID]['http_broadcast_port'], $rErrNo, $rErrStr, 3) || @fsockopen($rServers[SERVER_ID]['server_ip'], $rServers[SERVER_ID]['https_broadcast_port'], $rErrNo, $rErrStr, 3)) { - $rRemoteStatus = true; - } else { - $rRemoteStatus = false; - } + if (@fsockopen($rServers[SERVER_ID]['server_ip'], $rServers[SERVER_ID]['http_broadcast_port'], $rErrNo, $rErrStr, 3) || @fsockopen($rServers[SERVER_ID]['server_ip'], $rServers[SERVER_ID]['https_broadcast_port'], $rErrNo, $rErrStr, 3)) { + $rRemoteStatus = true; + } else { + $rRemoteStatus = false; + } - if (SettingsManager::get('redis_handler')) { - $rConnections = $rServers[SERVER_ID]['connections']; - $rUsers = $rServers[SERVER_ID]['users']; - $rAllUsers = 0; - foreach (array_keys($rServers) as $rServerID) { - if ($rServers[$rServerID]['server_online']) { - $rAllUsers += $rServers[$rServerID]['users']; - } - } - } else { - $db->query('SELECT COUNT(*) AS `count` FROM `lines_live` WHERE `server_id` = ? AND `hls_end` = 0;', SERVER_ID); - $rConnections = intval($db->get_row()['count']); - $db->query('SELECT `activity_id` FROM `lines_live` WHERE `server_id` = ? AND `hls_end` = 0 GROUP BY `user_id`;', SERVER_ID); - $rUsers = intval($db->num_rows()); - $db->query('SELECT `activity_id` FROM `lines_live` WHERE `hls_end` = 0 GROUP BY `user_id`;'); - $rAllUsers = intval($db->num_rows()); - } + if (SettingsManager::get('redis_handler')) { + $rConnections = $rServers[SERVER_ID]['connections']; + $rUsers = $rServers[SERVER_ID]['users']; + $rAllUsers = 0; + foreach (array_keys($rServers) as $rServerID) { + if ($rServers[$rServerID]['server_online']) { + $rAllUsers += $rServers[$rServerID]['users']; + } + } + } else { + $db->query('SELECT COUNT(*) AS `count` FROM `lines_live` WHERE `server_id` = ? AND `hls_end` = 0;', SERVER_ID); + $rConnections = intval($db->get_row()['count']); + $db->query('SELECT `activity_id` FROM `lines_live` WHERE `server_id` = ? AND `hls_end` = 0 GROUP BY `user_id`;', SERVER_ID); + $rUsers = intval($db->num_rows()); + $db->query('SELECT `activity_id` FROM `lines_live` WHERE `hls_end` = 0 GROUP BY `user_id`;'); + $rAllUsers = intval($db->num_rows()); + } - $db->query('SELECT COUNT(*) AS `count` FROM `streams_servers` LEFT JOIN `streams` ON `streams`.`id` = `streams_servers`.`stream_id` WHERE `server_id` = ? AND `pid` > 0 AND `type` = 1;', SERVER_ID); - $rStreams = intval($db->get_row()['count']); + $db->query('SELECT COUNT(*) AS `count` FROM `streams_servers` LEFT JOIN `streams` ON `streams`.`id` = `streams_servers`.`stream_id` WHERE `server_id` = ? AND `pid` > 0 AND `type` = 1;', SERVER_ID); + $rStreams = intval($db->get_row()['count']); - $rPing = 0; - if (!$rServers[SERVER_ID]['is_main']) { - $rMainID = null; - foreach ($rServers as $rServerID => $rServerArray) { - if ($rServerArray['is_main']) { - $rMainID = $rServerID; - break; - } - } - if ($rMainID) { - $rPing = $this->pingServer($rServers[$rMainID]['server_ip'], $rServers[$rMainID]['http_broadcast_port']); - } - } + $rPing = 0; + if (!$rServers[SERVER_ID]['is_main']) { + $rMainID = null; + foreach ($rServers as $rServerID => $rServerArray) { + if ($rServerArray['is_main']) { + $rMainID = $rServerID; + break; + } + } + if ($rMainID) { + $rPing = $this->pingServer($rServers[$rMainID]['server_ip'], $rServers[$rMainID]['http_broadcast_port']); + } + } - $rSysCtl = file_get_contents('/etc/sysctl.conf'); - $rGovernors = array(); - if (shell_exec('which cpufreq-info')) { - $rGovernors = array_filter(explode(' ', trim(shell_exec('cpufreq-info -g') ?? ''))); - } + $rSysCtl = file_get_contents('/etc/sysctl.conf'); + $rGovernors = []; + if (shell_exec('which cpufreq-info')) { + $rGovernors = array_filter(explode(' ', trim(shell_exec('cpufreq-info -g') ?? ''))); + } - $rAddresses = array_values(array_unique(array_map('trim', explode("\n", shell_exec("ip -4 addr | grep -oP '(?<=inet\\s)\\d+(\\.\\d+){3}'"))))); + $rAddresses = array_values(array_unique(array_map('trim', explode("\n", shell_exec("ip -4 addr | grep -oP '(?<=inet\\s)\\d+(\\.\\d+){3}'"))))); - $db->query('INSERT INTO `servers_stats`(`server_id`, `connections`, `total_users`, `users`, `streams`, `cpu`, `cpu_cores`, `cpu_avg`, `total_mem`, `total_mem_free`, `total_mem_used`, `total_mem_used_percent`, `total_disk_space`, `uptime`, `total_running_streams`, `bytes_sent`, `bytes_received`, `bytes_sent_total`, `bytes_received_total`, `cpu_load_average`, `gpu_info`, `iostat_info`, `time`) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UNIX_TIMESTAMP());', SERVER_ID, $rConnections, $rAllUsers, $rUsers, $rStreams, $rStats['cpu'], $rStats['cpu_cores'], $rStats['cpu_avg'], $rStats['total_mem'], $rStats['total_mem_free'], $rStats['total_mem_used'], $rStats['total_mem_used_percent'], $rStats['total_disk_space'], $rStats['uptime'], $rStats['total_running_streams'], $rStats['bytes_sent'], $rStats['bytes_received'], $rStats['bytes_sent_total'], $rStats['bytes_received_total'], $rStats['cpu_load_average'], json_encode($rStats['gpu_info'], JSON_UNESCAPED_UNICODE), json_encode($rStats['iostat_info'], JSON_UNESCAPED_UNICODE)); + $db->query('INSERT INTO `servers_stats`(`server_id`, `connections`, `total_users`, `users`, `streams`, `cpu`, `cpu_cores`, `cpu_avg`, `total_mem`, `total_mem_free`, `total_mem_used`, `total_mem_used_percent`, `total_disk_space`, `uptime`, `total_running_streams`, `bytes_sent`, `bytes_received`, `bytes_sent_total`, `bytes_received_total`, `cpu_load_average`, `gpu_info`, `iostat_info`, `time`) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UNIX_TIMESTAMP());', SERVER_ID, $rConnections, $rAllUsers, $rUsers, $rStreams, $rStats['cpu'], $rStats['cpu_cores'], $rStats['cpu_avg'], $rStats['total_mem'], $rStats['total_mem_free'], $rStats['total_mem_used'], $rStats['total_mem_used_percent'], $rStats['total_disk_space'], $rStats['uptime'], $rStats['total_running_streams'], $rStats['bytes_sent'], $rStats['bytes_received'], $rStats['bytes_sent_total'], $rStats['bytes_received_total'], $rStats['cpu_load_average'], json_encode($rStats['gpu_info'], JSON_UNESCAPED_UNICODE), json_encode($rStats['iostat_info'], JSON_UNESCAPED_UNICODE)); - $db->query('UPDATE `servers` SET `remote_status` = ?, `xc_vm_version` = ?, `server_hardware` = ?,`whitelist_ips` = ?, `governors` = ?, `sysctl` = ?, `video_devices` = ?, `audio_devices` = ?, `gpu_info` = ?, `interfaces` = ?, `time_offset` = ' . intval(time()) . ' - UNIX_TIMESTAMP(), `ping` = ? WHERE `id` = ?', $rRemoteStatus, XC_VM_VERSION, json_encode($rHardware, JSON_UNESCAPED_UNICODE), json_encode($rAddresses, JSON_UNESCAPED_UNICODE), json_encode($rGovernors, JSON_UNESCAPED_UNICODE), $rSysCtl, json_encode($rStats['video_devices'], JSON_UNESCAPED_UNICODE), json_encode($rStats['audio_devices'], JSON_UNESCAPED_UNICODE), json_encode($rStats['gpu_info'], JSON_UNESCAPED_UNICODE), json_encode($rStats['interfaces'], JSON_UNESCAPED_UNICODE), $rPing, SERVER_ID); + $db->query('UPDATE `servers` SET `remote_status` = ?, `xc_vm_version` = ?, `server_hardware` = ?,`whitelist_ips` = ?, `governors` = ?, `sysctl` = ?, `video_devices` = ?, `audio_devices` = ?, `gpu_info` = ?, `interfaces` = ?, `time_offset` = ' . intval(time()) . ' - UNIX_TIMESTAMP(), `ping` = ? WHERE `id` = ?', $rRemoteStatus, XC_VM_VERSION, json_encode($rHardware, JSON_UNESCAPED_UNICODE), json_encode($rAddresses, JSON_UNESCAPED_UNICODE), json_encode($rGovernors, JSON_UNESCAPED_UNICODE), $rSysCtl, json_encode($rStats['video_devices'], JSON_UNESCAPED_UNICODE), json_encode($rStats['audio_devices'], JSON_UNESCAPED_UNICODE), json_encode($rStats['gpu_info'], JSON_UNESCAPED_UNICODE), json_encode($rStats['interfaces'], JSON_UNESCAPED_UNICODE), $rPing, SERVER_ID); - if ($rServers[SERVER_ID]['is_main']) { - foreach ($rServers as $rServerID => $rServerArray) { - if ($rServerArray['server_online'] != $rServerArray['last_status']) { - $db->query('UPDATE `servers` SET `last_status` = ? WHERE `id` = ?;', $rServerArray['server_online'], $rServerID); - } - } - $db->query('DELETE FROM `signals` WHERE `time` <= ?;', time() - 86400); - } - } + if ($rServers[SERVER_ID]['is_main']) { + foreach ($rServers as $rServerID => $rServerArray) { + if ($rServerArray['server_online'] != $rServerArray['last_status']) { + $db->query('UPDATE `servers` SET `last_status` = ? WHERE `id` = ?;', $rServerArray['server_online'], $rServerID); + } + } + $db->query('DELETE FROM `signals` WHERE `time` <= ?;', time() - 86400); + } + } } diff --git a/src/Cli/CronJobs/StatsCronJob.php b/src/Cli/CronJobs/StatsCronJob.php index 45c9a4c8..7c7efb7e 100644 --- a/src/Cli/CronJobs/StatsCronJob.php +++ b/src/Cli/CronJobs/StatsCronJob.php @@ -17,73 +17,73 @@ use XcVm\Domain\Server\ServerRepository; */ class StatsCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:stats'; - } + public function getName(): string { + return 'cron:stats'; + } - public function getDescription(): string { - return 'Cron: recalculate stream statistics (rating, uptime, connections)'; - } + public function getDescription(): string { + return 'Cron: recalculate stream statistics (rating, uptime, connections)'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->initCron('XC_VM[Stats]'); + $this->initCron('XC_VM[Stats]'); - $rTimeout = 60; - set_time_limit($rTimeout); - ini_set('max_execution_time', $rTimeout); + $rTimeout = 60; + set_time_limit($rTimeout); + ini_set('max_execution_time', $rTimeout); - $this->loadCron(); + $this->loadCron(); - return 0; - } + return 0; + } - private function loadCron(): void { - global $db; + private function loadCron(): void { + global $db; - if (!ServerRepository::getAll()[SERVER_ID]['is_main']) { - return; - } + if (!ServerRepository::getAll()[SERVER_ID]['is_main']) { + return; + } - $rTime = time(); - $rDates = array( - 'today' => array($rTime - 86400, $rTime), - 'week' => array($rTime - 604800, $rTime), - 'month' => array($rTime - 2592000, $rTime), - 'all' => array(0, $rTime), - ); + $rTime = time(); + $rDates = [ + 'today' => [$rTime - 86400, $rTime], + 'week' => [$rTime - 604800, $rTime], + 'month' => [$rTime - 2592000, $rTime], + 'all' => [0, $rTime], + ]; - $db->query('TRUNCATE `streams_stats`;'); + $db->query('TRUNCATE `streams_stats`;'); - foreach ($rDates as $rType => $rDate) { - $rStats = array(); + foreach ($rDates as $rType => $rDate) { + $rStats = []; - $db->query('SELECT `stream_id`, COUNT(*) AS `connections`, SUM(`date_end` - `date_start`) AS `time`, COUNT(DISTINCT(`user_id`)) AS `users` FROM `lines_activity` LEFT JOIN `streams` ON `streams`.`id` = `lines_activity`.`stream_id` WHERE `date_start` > ? AND `date_end` <= ? GROUP BY `stream_id`;', $rDate[0], $rDate[1]); - if ($db->num_rows() > 0) { - foreach ($db->get_rows() as $rRow) { - $rStats[$rRow['stream_id']] = array('rank' => 0, 'time' => intval($rRow['time']), 'connections' => $rRow['connections'], 'users' => $rRow['users']); - } - } + $db->query('SELECT `stream_id`, COUNT(*) AS `connections`, SUM(`date_end` - `date_start`) AS `time`, COUNT(DISTINCT(`user_id`)) AS `users` FROM `lines_activity` LEFT JOIN `streams` ON `streams`.`id` = `lines_activity`.`stream_id` WHERE `date_start` > ? AND `date_end` <= ? GROUP BY `stream_id`;', $rDate[0], $rDate[1]); + if ($db->num_rows() > 0) { + foreach ($db->get_rows() as $rRow) { + $rStats[$rRow['stream_id']] = ['rank' => 0, 'time' => intval($rRow['time']), 'connections' => $rRow['connections'], 'users' => $rRow['users']]; + } + } - $db->query('SELECT `stream_id`, SUM(`date_end` - `date_start`) AS `time` FROM `lines_activity` LEFT JOIN `streams` ON `streams`.`id` = `lines_activity`.`stream_id` WHERE `date_start` > ? AND `date_end` <= ? GROUP BY `stream_id` ORDER BY `time` DESC, `stream_id` DESC;', $rDate[0], $rDate[1]); - if ($db->num_rows() > 0) { - $rRank = 1; - foreach ($db->get_rows() as $rRow) { - if (isset($rStats[$rRow['stream_id']])) { - $rStats[$rRow['stream_id']]['rank'] = $rRank; - $rRank++; - } - } - } + $db->query('SELECT `stream_id`, SUM(`date_end` - `date_start`) AS `time` FROM `lines_activity` LEFT JOIN `streams` ON `streams`.`id` = `lines_activity`.`stream_id` WHERE `date_start` > ? AND `date_end` <= ? GROUP BY `stream_id` ORDER BY `time` DESC, `stream_id` DESC;', $rDate[0], $rDate[1]); + if ($db->num_rows() > 0) { + $rRank = 1; + foreach ($db->get_rows() as $rRow) { + if (isset($rStats[$rRow['stream_id']])) { + $rStats[$rRow['stream_id']]['rank'] = $rRank; + $rRank++; + } + } + } - foreach ($rStats as $rStreamID => $rArray) { - $db->query('INSERT INTO `streams_stats`(`stream_id`, `rank`, `time`, `connections`, `users`, `type`) VALUES(?, ?, ?, ?, ?, ?);', $rStreamID, $rArray['rank'], $rArray['time'], $rArray['connections'], $rArray['users'], $rType); - } - } - } + foreach ($rStats as $rStreamID => $rArray) { + $db->query('INSERT INTO `streams_stats`(`stream_id`, `rank`, `time`, `connections`, `users`, `type`) VALUES(?, ?, ?, ?, ?, ?);', $rStreamID, $rArray['rank'], $rArray['time'], $rArray['connections'], $rArray['users'], $rType); + } + } + } } diff --git a/src/Cli/CronJobs/StreamsCronJob.php b/src/Cli/CronJobs/StreamsCronJob.php index 147c9ba5..3f2d0265 100644 --- a/src/Cli/CronJobs/StreamsCronJob.php +++ b/src/Cli/CronJobs/StreamsCronJob.php @@ -26,429 +26,429 @@ use XcVm\Streaming\Fanout\FanoutClient; */ class StreamsCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:streams'; - } + public function getName(): string { + return 'cron:streams'; + } - public function getDescription(): string { - return 'Cron: check live streams, monitors, on-demand, rogue PIDs'; - } + public function getDescription(): string { + return 'Cron: check live streams, monitors, on-demand, rogue PIDs'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->initCron('XC_VM[Live Checker]'); - $this->loadCron(); + $this->initCron('XC_VM[Live Checker]'); + $this->loadCron(); - return 0; - } + return 0; + } - /** - * Whether handing a stream (whose watchdog is gone) to the fanout supervisor - * must restart its producer rather than adopt it. - * - * An ffmpeg producer's feed into the daemon is a tee slave that, once broken - * by a daemon restart, stays broken for the life of the process — adopting it - * would leave the daemon's viewers on dead air until a stall restart. The - * native remuxer redials the ingest socket by itself, so it is adopted and - * the channel does not blink. - */ - private static function handOverNeedsRestart(array $rStream): int { - $rPID = file_exists(STREAMS_PATH . $rStream['stream_id'] . '_.pid') ? intval(@file_get_contents(STREAMS_PATH . $rStream['stream_id'] . '_.pid')) : intval($rStream['pid']); - if (!ProcessManager::isStreamRunning($rPID, $rStream['stream_id'])) { - return 0; // nothing running: a plain start - } - $rExe = basename((string) @readlink('/proc/' . $rPID . '/exe')); - return (strpos($rExe, 'ffmpeg') === 0 && FanoutClient::daemonStreamMissing(intval($rStream['stream_id']))) ? 1 : 0; - } + /** + * Whether handing a stream (whose watchdog is gone) to the fanout supervisor + * must restart its producer rather than adopt it. + * + * An ffmpeg producer's feed into the daemon is a tee slave that, once broken + * by a daemon restart, stays broken for the life of the process — adopting it + * would leave the daemon's viewers on dead air until a stall restart. The + * native remuxer redials the ingest socket by itself, so it is adopted and + * the channel does not blink. + */ + private static function handOverNeedsRestart(array $rStream): int { + $rPID = file_exists(STREAMS_PATH . $rStream['stream_id'] . '_.pid') ? intval(@file_get_contents(STREAMS_PATH . $rStream['stream_id'] . '_.pid')) : intval($rStream['pid']); + if (!ProcessManager::isStreamRunning($rPID, $rStream['stream_id'])) { + return 0; // nothing running: a plain start + } + $rExe = basename((string) @readlink('/proc/' . $rPID . '/exe')); + return (strpos($rExe, 'ffmpeg') === 0 && FanoutClient::daemonStreamMissing(intval($rStream['stream_id']))) ? 1 : 0; + } - /** - * Fold the producer's CPU, memory and kind into the stream's progress JSON — - * what the admin streams list shows per stream. - * - * Only this node can read its own /proc, so the reading is taken here and - * travels to the panel in the row the cron already writes. CPU is the - * difference against this pass's predecessor, so it is the average over the - * last minute rather than ffmpeg's lifetime average — and where there is no - * usable predecessor (the producer's first pass, or a new pid after a - * restart) the lifetime average stands in, so the column never waits a pass - * to show a figure. - * - * The previous reading is kept beside the stream's files (`_.usage`, on - * the streams tmpfs, removed with the rest of `_*` when it stops) rather - * than in the database row: it is this node's bookkeeping, and a value that - * has to survive a round trip through a row other code also rewrites is one - * that sometimes does not. - * - * @param string $rProgressJson This pass's progress report. - * @param int $rStreamID The stream. - * @param int $rPID The producer's pid. - * @return string The JSON to store. - */ - private static function withResourceUsage(string $rProgressJson, int $rStreamID, int $rPID): string { - $rProgress = json_decode($rProgressJson, true); - if (!is_array($rProgress)) { - $rProgress = array(); - } - // Keys an earlier version kept in the row for the CPU difference. - unset($rProgress['cpu_t'], $rProgress['cpu_at']); + /** + * Fold the producer's CPU, memory and kind into the stream's progress JSON — + * what the admin streams list shows per stream. + * + * Only this node can read its own /proc, so the reading is taken here and + * travels to the panel in the row the cron already writes. CPU is the + * difference against this pass's predecessor, so it is the average over the + * last minute rather than ffmpeg's lifetime average — and where there is no + * usable predecessor (the producer's first pass, or a new pid after a + * restart) the lifetime average stands in, so the column never waits a pass + * to show a figure. + * + * The previous reading is kept beside the stream's files (`_.usage`, on + * the streams tmpfs, removed with the rest of `_*` when it stops) rather + * than in the database row: it is this node's bookkeeping, and a value that + * has to survive a round trip through a row other code also rewrites is one + * that sometimes does not. + * + * @param string $rProgressJson This pass's progress report. + * @param int $rStreamID The stream. + * @param int $rPID The producer's pid. + * @return string The JSON to store. + */ + private static function withResourceUsage(string $rProgressJson, int $rStreamID, int $rPID): string { + $rProgress = json_decode($rProgressJson, true); + if (!is_array($rProgress)) { + $rProgress = []; + } + // Keys an earlier version kept in the row for the CPU difference. + unset($rProgress['cpu_t'], $rProgress['cpu_at']); - $rSamplePath = STREAMS_PATH . $rStreamID . '_.usage'; - $rSample = ProcessManager::resourceSample($rPID); - if ($rSample === null) { - @unlink($rSamplePath); - unset($rProgress['cpu'], $rProgress['mem'], $rProgress['producer']); - return json_encode($rProgress); - } + $rSamplePath = STREAMS_PATH . $rStreamID . '_.usage'; + $rSample = ProcessManager::resourceSample($rPID); + if ($rSample === null) { + @unlink($rSamplePath); + unset($rProgress['cpu'], $rProgress['mem'], $rProgress['producer']); + return json_encode($rProgress); + } - $rCPU = null; - $rPrevious = json_decode((string) @file_get_contents($rSamplePath), true); - if (is_array($rPrevious) && intval($rPrevious['pid'] ?? 0) === $rPID) { - $rCPU = ProcessManager::cpuPercent($rSample, $rPrevious); - } - if ($rCPU === null) { - $rCPU = ProcessManager::cpuPercentSinceStart($rSample); - } - @file_put_contents($rSamplePath, json_encode(array('pid' => $rPID, 'ticks' => $rSample['ticks'], 'at' => $rSample['at']))); + $rCPU = null; + $rPrevious = json_decode((string) @file_get_contents($rSamplePath), true); + if (is_array($rPrevious) && intval($rPrevious['pid'] ?? 0) === $rPID) { + $rCPU = ProcessManager::cpuPercent($rSample, $rPrevious); + } + if ($rCPU === null) { + $rCPU = ProcessManager::cpuPercentSinceStart($rSample); + } + @file_put_contents($rSamplePath, json_encode(['pid' => $rPID, 'ticks' => $rSample['ticks'], 'at' => $rSample['at']])); - $rProgress['cpu'] = $rCPU; - $rProgress['mem'] = $rSample['rss']; - $rProgress['producer'] = ProcessManager::producerKind($rPID); + $rProgress['cpu'] = $rCPU; + $rProgress['mem'] = $rSample['rss']; + $rProgress['producer'] = ProcessManager::producerKind($rPID); - return json_encode($rProgress); - } + return json_encode($rProgress); + } - private function loadCron(): void { - $rRedis = SettingsManager::getBool('redis_handler'); - global $db; + private function loadCron(): void { + $rRedis = SettingsManager::getBool('redis_handler'); + global $db; - if (!ProcessManager::isNginxRunning()) { - echo 'XC_VM not running...' . "\n"; - } + if (!ProcessManager::isNginxRunning()) { + echo 'XC_VM not running...' . "\n"; + } - if ($rRedis) { - RedisManager::ensureConnected(); - } + if ($rRedis) { + RedisManager::ensureConnected(); + } - $rActivePIDs = array(); - $rStreamIDs = array(); + $rActivePIDs = []; + $rStreamIDs = []; - // Bring streams_servers in step with the fanout supervisor first, so the - // pass below reads what is actually running. $rSupervised is the set it - // is supervising, or null when it cannot be asked — unknown, which the - // checks below treat as "not supervised" exactly as before this existed. - $rStates = FanoutClient::monitorStates(); - $rSupervised = StreamProcess::reconcileSupervised($rStates); - $rSupervisedSet = array_flip($rSupervised ?? array()); - // While the daemon takes hand-overs, streams still under a PHP monitor - // (started before supervision was on, or while the daemon was down) are - // moved to it — adopting their running encoder, so they do not restart. - $rMigrate = $rStates !== null && !empty($rStates['accepting']) && StreamProcess::supervisionEnabled(); + // Bring streams_servers in step with the fanout supervisor first, so the + // pass below reads what is actually running. $rSupervised is the set it + // is supervising, or null when it cannot be asked — unknown, which the + // checks below treat as "not supervised" exactly as before this existed. + $rStates = FanoutClient::monitorStates(); + $rSupervised = StreamProcess::reconcileSupervised($rStates); + $rSupervisedSet = array_flip($rSupervised ?? []); + // While the daemon takes hand-overs, streams still under a PHP monitor + // (started before supervision was on, or while the daemon was down) are + // moved to it — adopting their running encoder, so they do not restart. + $rMigrate = $rStates !== null && !empty($rStates['accepting']) && StreamProcess::supervisionEnabled(); - if ($rRedis) { - $db->query('SELECT t2.stream_display_name, t2.delay_minutes, t1.stream_started, t1.stream_info, t2.fps_restart, t1.stream_status, t1.progress_info, t1.stream_id, t1.monitor_pid, t1.on_demand, t1.server_stream_id, t1.pid, servers_attached.attached, t2.vframes_server_id, t2.vframes_pid, t2.tv_archive_server_id, t2.tv_archive_pid FROM `streams_servers` t1 INNER JOIN `streams` t2 ON t2.id = t1.stream_id AND t2.direct_source = 0 INNER JOIN `streams_types` t3 ON t3.type_id = t2.type LEFT JOIN (SELECT `stream_id`, COUNT(*) AS `attached` FROM `streams_servers` WHERE `parent_id` = ? AND `pid` IS NOT NULL AND `pid` > 0 AND `monitor_pid` IS NOT NULL AND `monitor_pid` > 0 GROUP BY `stream_id`) AS `servers_attached` ON `servers_attached`.`stream_id` = t1.`stream_id` WHERE (t1.pid IS NOT NULL OR t1.stream_status <> 0 OR t1.to_analyze = 1) AND t1.server_id = ? AND t3.live = 1', SERVER_ID, SERVER_ID); - } else { - $db->query("SELECT t2.stream_display_name, t2.delay_minutes, t1.stream_started, t1.stream_info, t2.fps_restart, t1.stream_status, t1.progress_info, t1.stream_id, t1.monitor_pid, t1.on_demand, t1.server_stream_id, t1.pid, clients.online_clients, clients_hls.online_clients_hls, servers_attached.attached, t2.vframes_server_id, t2.vframes_pid, t2.tv_archive_server_id, t2.tv_archive_pid FROM `streams_servers` t1 INNER JOIN `streams` t2 ON t2.id = t1.stream_id AND t2.direct_source = 0 INNER JOIN `streams_types` t3 ON t3.type_id = t2.type LEFT JOIN (SELECT stream_id, COUNT(*) as online_clients FROM `lines_live` WHERE `server_id` = ? AND `hls_end` = 0 GROUP BY stream_id) AS clients ON clients.stream_id = t1.stream_id LEFT JOIN (SELECT `stream_id`, COUNT(*) AS `attached` FROM `streams_servers` WHERE `parent_id` = ? AND `pid` IS NOT NULL AND `pid` > 0 AND `monitor_pid` IS NOT NULL AND `monitor_pid` > 0 GROUP BY `stream_id`) AS `servers_attached` ON `servers_attached`.`stream_id` = t1.`stream_id` LEFT JOIN (SELECT stream_id, COUNT(*) as online_clients_hls FROM `lines_live` WHERE `server_id` = ? AND `container` = 'hls' AND `hls_end` = 0 GROUP BY stream_id) AS clients_hls ON clients_hls.stream_id = t1.stream_id WHERE (t1.pid IS NOT NULL OR t1.stream_status <> 0 OR t1.to_analyze = 1) AND t1.server_id = ? AND t3.live = 1", SERVER_ID, SERVER_ID, SERVER_ID, SERVER_ID); - } + if ($rRedis) { + $db->query('SELECT t2.stream_display_name, t2.delay_minutes, t1.stream_started, t1.stream_info, t2.fps_restart, t1.stream_status, t1.progress_info, t1.stream_id, t1.monitor_pid, t1.on_demand, t1.server_stream_id, t1.pid, servers_attached.attached, t2.vframes_server_id, t2.vframes_pid, t2.tv_archive_server_id, t2.tv_archive_pid FROM `streams_servers` t1 INNER JOIN `streams` t2 ON t2.id = t1.stream_id AND t2.direct_source = 0 INNER JOIN `streams_types` t3 ON t3.type_id = t2.type LEFT JOIN (SELECT `stream_id`, COUNT(*) AS `attached` FROM `streams_servers` WHERE `parent_id` = ? AND `pid` IS NOT NULL AND `pid` > 0 AND `monitor_pid` IS NOT NULL AND `monitor_pid` > 0 GROUP BY `stream_id`) AS `servers_attached` ON `servers_attached`.`stream_id` = t1.`stream_id` WHERE (t1.pid IS NOT NULL OR t1.stream_status <> 0 OR t1.to_analyze = 1) AND t1.server_id = ? AND t3.live = 1', SERVER_ID, SERVER_ID); + } else { + $db->query("SELECT t2.stream_display_name, t2.delay_minutes, t1.stream_started, t1.stream_info, t2.fps_restart, t1.stream_status, t1.progress_info, t1.stream_id, t1.monitor_pid, t1.on_demand, t1.server_stream_id, t1.pid, clients.online_clients, clients_hls.online_clients_hls, servers_attached.attached, t2.vframes_server_id, t2.vframes_pid, t2.tv_archive_server_id, t2.tv_archive_pid FROM `streams_servers` t1 INNER JOIN `streams` t2 ON t2.id = t1.stream_id AND t2.direct_source = 0 INNER JOIN `streams_types` t3 ON t3.type_id = t2.type LEFT JOIN (SELECT stream_id, COUNT(*) as online_clients FROM `lines_live` WHERE `server_id` = ? AND `hls_end` = 0 GROUP BY stream_id) AS clients ON clients.stream_id = t1.stream_id LEFT JOIN (SELECT `stream_id`, COUNT(*) AS `attached` FROM `streams_servers` WHERE `parent_id` = ? AND `pid` IS NOT NULL AND `pid` > 0 AND `monitor_pid` IS NOT NULL AND `monitor_pid` > 0 GROUP BY `stream_id`) AS `servers_attached` ON `servers_attached`.`stream_id` = t1.`stream_id` LEFT JOIN (SELECT stream_id, COUNT(*) as online_clients_hls FROM `lines_live` WHERE `server_id` = ? AND `container` = 'hls' AND `hls_end` = 0 GROUP BY stream_id) AS clients_hls ON clients_hls.stream_id = t1.stream_id WHERE (t1.pid IS NOT NULL OR t1.stream_status <> 0 OR t1.to_analyze = 1) AND t1.server_id = ? AND t3.live = 1", SERVER_ID, SERVER_ID, SERVER_ID, SERVER_ID); + } - if ($db->num_rows() > 0) { - foreach ($db->get_rows() as $rStream) { - echo 'Stream ID: ' . $rStream['stream_id'] . "\n"; - $rStreamIDs[] = $rStream['stream_id']; + if ($db->num_rows() > 0) { + foreach ($db->get_rows() as $rStream) { + echo 'Stream ID: ' . $rStream['stream_id'] . "\n"; + $rStreamIDs[] = $rStream['stream_id']; - $rIsSupervised = isset($rSupervisedSet[intval($rStream['stream_id'])]); - // superviseStream, not startMonitor: a stream the daemon will not - // take (delay, created channels) must keep the PHP monitor it has, - // not have a second one spawned beside it every pass. - if ($rMigrate && !$rIsSupervised && ProcessManager::isMonitorAlive($rStream['monitor_pid'], $rStream['stream_id'])) { - if (StreamProcess::superviseStream(intval($rStream['stream_id']), false)) { - echo 'Handed over to the fanout supervisor.' . "\n\n"; - continue; - } - } - if ($rIsSupervised || ProcessManager::isMonitorAlive($rStream['monitor_pid'], $rStream['stream_id']) || $rStream['on_demand']) { - if ($rStream['on_demand'] == 1 && $rStream['attached'] == 0) { - if ($rRedis) { - $rCount = 0; - $rRedis = RedisManager::instance(); - if ($rRedis) { - $rKeys = $rRedis->zRangeByScore('STREAM#' . $rStream['stream_id'], '-inf', '+inf'); - if (count($rKeys) > 0) { - $rConnections = array_map('igbinary_unserialize', $rRedis->mGet($rKeys)); - foreach ($rConnections as $rConnection) { - if ($rConnection && $rConnection['server_id'] == SERVER_ID) { - $rCount++; - } - } - } - } - $rStream['online_clients'] = $rCount; - } + $rIsSupervised = isset($rSupervisedSet[intval($rStream['stream_id'])]); + // superviseStream, not startMonitor: a stream the daemon will not + // take (delay, created channels) must keep the PHP monitor it has, + // not have a second one spawned beside it every pass. + if ($rMigrate && !$rIsSupervised && ProcessManager::isMonitorAlive($rStream['monitor_pid'], $rStream['stream_id'])) { + if (StreamProcess::superviseStream(intval($rStream['stream_id']), false)) { + echo 'Handed over to the fanout supervisor.' . "\n\n"; + continue; + } + } + if ($rIsSupervised || ProcessManager::isMonitorAlive($rStream['monitor_pid'], $rStream['stream_id']) || $rStream['on_demand']) { + if ($rStream['on_demand'] == 1 && $rStream['attached'] == 0) { + if ($rRedis) { + $rCount = 0; + $rRedis = RedisManager::instance(); + if ($rRedis) { + $rKeys = $rRedis->zRangeByScore('STREAM#' . $rStream['stream_id'], '-inf', '+inf'); + if (count($rKeys) > 0) { + $rConnections = array_map('igbinary_unserialize', $rRedis->mGet($rKeys)); + foreach ($rConnections as $rConnection) { + if ($rConnection && $rConnection['server_id'] == SERVER_ID) { + $rCount++; + } + } + } + } + $rStream['online_clients'] = $rCount; + } - $rAdminQueue = $rQueue = 0; - if (SettingsManager::getBool('on_demand_instant_off') && file_exists(SIGNALS_TMP_PATH . 'queue_' . intval($rStream['stream_id']))) { - foreach ((igbinary_unserialize(file_get_contents(SIGNALS_TMP_PATH . 'queue_' . intval($rStream['stream_id']))) ?: array()) as $rPID) { - if (ProcessManager::isRunning($rPID, 'php-fpm')) { - $rQueue++; - } - } - } - if (file_exists(SIGNALS_TMP_PATH . 'admin_' . intval($rStream['stream_id']))) { - if (time() - filemtime(SIGNALS_TMP_PATH . 'admin_' . intval($rStream['stream_id'])) <= 30) { - $rAdminQueue = 1; - } else { - unlink(SIGNALS_TMP_PATH . 'admin_' . intval($rStream['stream_id'])); - } - } - if ($rQueue == 0 && $rAdminQueue == 0 && $rStream['online_clients'] == 0 && (file_exists(STREAMS_PATH . $rStream['stream_id'] . '_.m3u8') || SettingsManager::getInt('on_demand_wait_time') < time() - intval($rStream['stream_started']) || $rStream['stream_status'] == 1)) { - echo 'Stop on-demand stream...' . "\n\n"; - StreamProcess::stopStream($rStream['stream_id'], true); - // Stopped: nothing below applies (it would start a thumbnail - // and a TV archive worker for the stream just stopped). - continue; - } - } + $rAdminQueue = $rQueue = 0; + if (SettingsManager::getBool('on_demand_instant_off') && file_exists(SIGNALS_TMP_PATH . 'queue_' . intval($rStream['stream_id']))) { + foreach ((igbinary_unserialize(file_get_contents(SIGNALS_TMP_PATH . 'queue_' . intval($rStream['stream_id']))) ?: []) as $rPID) { + if (ProcessManager::isRunning($rPID, 'php-fpm')) { + $rQueue++; + } + } + } + if (file_exists(SIGNALS_TMP_PATH . 'admin_' . intval($rStream['stream_id']))) { + if (time() - filemtime(SIGNALS_TMP_PATH . 'admin_' . intval($rStream['stream_id'])) <= 30) { + $rAdminQueue = 1; + } else { + unlink(SIGNALS_TMP_PATH . 'admin_' . intval($rStream['stream_id'])); + } + } + if ($rQueue == 0 && $rAdminQueue == 0 && $rStream['online_clients'] == 0 && (file_exists(STREAMS_PATH . $rStream['stream_id'] . '_.m3u8') || SettingsManager::getInt('on_demand_wait_time') < time() - intval($rStream['stream_started']) || $rStream['stream_status'] == 1)) { + echo 'Stop on-demand stream...' . "\n\n"; + StreamProcess::stopStream($rStream['stream_id'], true); + // Stopped: nothing below applies (it would start a thumbnail + // and a TV archive worker for the stream just stopped). + continue; + } + } - if ($rStream['vframes_server_id'] == SERVER_ID && !ProcessManager::isNamedProcessRunning($rStream['vframes_pid'], 'Thumbnail', $rStream['stream_id'])) { - echo 'Start Thumbnail...' . "\n"; - StreamProcess::startThumbnail($rStream['stream_id']); - } - if ($rStream['tv_archive_server_id'] == SERVER_ID && !ProcessManager::isNamedProcessRunning($rStream['tv_archive_pid'], 'TVArchive', $rStream['stream_id'])) { - echo 'Start TV Archive...' . "\n"; - shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php archive ' . intval($rStream['stream_id']) . ' >/dev/null 2>/dev/null & echo $!'); - } + if ($rStream['vframes_server_id'] == SERVER_ID && !ProcessManager::isNamedProcessRunning($rStream['vframes_pid'], 'Thumbnail', $rStream['stream_id'])) { + echo 'Start Thumbnail...' . "\n"; + StreamProcess::startThumbnail($rStream['stream_id']); + } + if ($rStream['tv_archive_server_id'] == SERVER_ID && !ProcessManager::isNamedProcessRunning($rStream['tv_archive_pid'], 'TVArchive', $rStream['stream_id'])) { + echo 'Start TV Archive...' . "\n"; + shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php archive ' . intval($rStream['stream_id']) . ' >/dev/null 2>/dev/null & echo $!'); + } - foreach (glob(STREAMS_PATH . $rStream['stream_id'] . '_*.ts.enc') as $rFile) { - if (!file_exists(rtrim($rFile, '.enc'))) { - unlink($rFile); - } - } + foreach (glob(STREAMS_PATH . $rStream['stream_id'] . '_*.ts.enc') as $rFile) { + if (!file_exists(rtrim($rFile, '.enc'))) { + unlink($rFile); + } + } - if (file_exists(STREAMS_PATH . $rStream['stream_id'] . '_.pid')) { - $rPID = intval(file_get_contents(STREAMS_PATH . $rStream['stream_id'] . '_.pid')); - } else { - $rPID = intval(shell_exec("ps aux | grep -v grep | grep '/" . intval($rStream['stream_id']) . "_.m3u8' | awk '{print \$2}'")); - } - $rActivePIDs[] = intval($rPID); + if (file_exists(STREAMS_PATH . $rStream['stream_id'] . '_.pid')) { + $rPID = intval(file_get_contents(STREAMS_PATH . $rStream['stream_id'] . '_.pid')); + } else { + $rPID = intval(shell_exec("ps aux | grep -v grep | grep '/" . intval($rStream['stream_id']) . "_.m3u8' | awk '{print \$2}'")); + } + $rActivePIDs[] = intval($rPID); - $rPlaylist = STREAMS_PATH . $rStream['stream_id'] . '_.m3u8'; - if (ProcessManager::isStreamRunning($rPID, $rStream['stream_id']) && file_exists($rPlaylist)) { - // Re-feed after a daemon restart (ADR 0003, C-ops). This - // stream's ffmpeg is running and teeing into the daemon, - // but if the daemon restarted it wiped its in-memory - // registry and `onfail=ignore` silently dropped the tee - // slave — the daemon no longer knows the stream - // (control /streams/ → 404) and delivery has quietly - // fallen back to legacy. Restart it so buildLive - // re-registers the ingest and the daemon serves it again. - // Guard: only a reachable-daemon 404 (daemonStreamMissing), - // so a stopped daemon leaves legacy alone; throttled by a - // stamp so a stream whose ingest keeps failing is not - // restart-looped every cron tick. Proxy streams have no - // local ffmpeg, so they never reach this running branch. - // Only an ffmpeg producer needs the restart: a broken tee slave - // stays broken. The PHP relays (LLOD, loopback) and a delayed - // stream's DelayCommand re-register and redial the daemon by - // themselves (IngestFeeder), and restarting a delayed stream - // would throw its buffer away. - $rSelfFeeding = intval($rStream['delay_minutes'] ?? 0) > 0 || ProcessManager::producerKind($rPID) === 'php'; - if (!$rSelfFeeding && FanoutClient::daemonStreamMissing($rStream['stream_id'])) { - // The stamp lives outside STREAMS_PATH: the restart below - // runs `rm -f _*` there, which deleted a `_.refeed` - // stamp — and with it the 120 s throttle it was meant to be. - $rRefeedStamp = SIGNALS_TMP_PATH . 'refeed_' . intval($rStream['stream_id']); - if (!file_exists($rRefeedStamp) || time() - filemtime($rRefeedStamp) > 120) { - echo 'Daemon lost stream ' . $rStream['stream_id'] . ' (restarted) — re-feeding...' . "\n\n"; - touch($rRefeedStamp); - StreamProcess::startMonitor($rStream['stream_id'], 1); - continue; - } - } + $rPlaylist = STREAMS_PATH . $rStream['stream_id'] . '_.m3u8'; + if (ProcessManager::isStreamRunning($rPID, $rStream['stream_id']) && file_exists($rPlaylist)) { + // Re-feed after a daemon restart (ADR 0003, C-ops). This + // stream's ffmpeg is running and teeing into the daemon, + // but if the daemon restarted it wiped its in-memory + // registry and `onfail=ignore` silently dropped the tee + // slave — the daemon no longer knows the stream + // (control /streams/ → 404) and delivery has quietly + // fallen back to legacy. Restart it so buildLive + // re-registers the ingest and the daemon serves it again. + // Guard: only a reachable-daemon 404 (daemonStreamMissing), + // so a stopped daemon leaves legacy alone; throttled by a + // stamp so a stream whose ingest keeps failing is not + // restart-looped every cron tick. Proxy streams have no + // local ffmpeg, so they never reach this running branch. + // Only an ffmpeg producer needs the restart: a broken tee slave + // stays broken. The PHP relays (LLOD, loopback) and a delayed + // stream's DelayCommand re-register and redial the daemon by + // themselves (IngestFeeder), and restarting a delayed stream + // would throw its buffer away. + $rSelfFeeding = intval($rStream['delay_minutes'] ?? 0) > 0 || ProcessManager::producerKind($rPID) === 'php'; + if (!$rSelfFeeding && FanoutClient::daemonStreamMissing($rStream['stream_id'])) { + // The stamp lives outside STREAMS_PATH: the restart below + // runs `rm -f _*` there, which deleted a `_.refeed` + // stamp — and with it the 120 s throttle it was meant to be. + $rRefeedStamp = SIGNALS_TMP_PATH . 'refeed_' . intval($rStream['stream_id']); + if (!file_exists($rRefeedStamp) || time() - filemtime($rRefeedStamp) > 120) { + echo 'Daemon lost stream ' . $rStream['stream_id'] . ' (restarted) — re-feeding...' . "\n\n"; + touch($rRefeedStamp); + StreamProcess::startMonitor($rStream['stream_id'], 1); + continue; + } + } - echo 'Update Stream Information...' . "\n"; - $rBitrate = StreamUtils::getStreamBitrate('live', STREAMS_PATH . $rStream['stream_id'] . '_.m3u8'); - $rProgressPath = STREAMS_PATH . $rStream['stream_id'] . '_.progress'; - if (file_exists($rProgressPath)) { - // ffmpeg appends key=value progress reports to this file - // for the stream's whole life. Read only the tail, keep - // the last COMPLETE report block (terminated by a - // "progress=" line) and re-encode it as the JSON the rest - // of the panel expects. Then truncate so the file stays - // tiny on disk (ffmpeg keeps its write offset, so the - // hole left behind is sparse). - $rTail = ''; - $rFp = fopen($rProgressPath, 'rb'); - if ($rFp !== false) { - fseek($rFp, 0, SEEK_END); - if (ftell($rFp) > 16384) { - fseek($rFp, -16384, SEEK_END); - } else { - rewind($rFp); - } - $rTail = stream_get_contents($rFp); - fclose($rFp); - } - $rReport = array(); - $rCurrentReport = array(); - foreach (explode("\n", (string) $rTail) as $rLine) { - $rLine = trim($rLine); - if ($rLine === '') { - continue; - } - $rKV = explode('=', $rLine, 2); - if (count($rKV) !== 2) { - continue; - } - $rReportKey = trim($rKV[0]); - $rCurrentReport[$rReportKey] = trim($rKV[1]); - if ($rReportKey === 'progress') { - $rReport = $rCurrentReport; - $rCurrentReport = array(); - } - } - $rProgress = $rReport ? json_encode($rReport) : ($rStream['progress_info'] ?: json_encode(array())); - file_put_contents($rProgressPath, ''); - if ($rStream['fps_restart']) { - file_put_contents(STREAMS_PATH . $rStream['stream_id'] . '_.progress_check', $rProgress); - } - } else { - $rProgress = $rStream['progress_info']; - } - $rProgress = self::withResourceUsage((string) $rProgress, intval($rStream['stream_id']), $rPID); - // A supervised stream's codecs, resolution and bitrate come - // from the daemon, measured off the bytes (reconcileSupervised - // wrote them above); recomputing them here from a stream_info - // that nothing probes any more would blank them. Only the - // producer's progress report is this pass's to record. - if ($rIsSupervised) { - if ($rProgress !== $rStream['progress_info']) { - $db->query('UPDATE `streams_servers` SET `progress_info` = ? WHERE `server_stream_id` = ?', $rProgress, $rStream['server_stream_id']); - } - echo "\n"; - continue; - } - if (file_exists(STREAMS_PATH . $rStream['stream_id'] . '_.stream_info')) { - $rStreamInfo = file_get_contents(STREAMS_PATH . $rStream['stream_id'] . '_.stream_info'); - unlink(STREAMS_PATH . $rStream['stream_id'] . '_.stream_info'); - } else { - $rStreamInfo = $rStream['stream_info']; - } - $rCompatible = 0; - $rAudioCodec = $rVideoCodec = $rResolution = null; - if ($rStreamInfo) { - $rStreamJSON = json_decode($rStreamInfo, true); - $rCompatible = intval(DiagnosticsService::checkCompatibility($rStreamJSON, SettingsManager::getBool('player_allow_hevc'))); - if (is_array($rStreamJSON) && isset($rStreamJSON['codecs']) && is_array($rStreamJSON['codecs'])) { - $rAudioCodec = isset($rStreamJSON['codecs']['audio']['codec_name']) ? $rStreamJSON['codecs']['audio']['codec_name'] : null; - $rVideoCodec = isset($rStreamJSON['codecs']['video']['codec_name']) ? $rStreamJSON['codecs']['video']['codec_name'] : null; - $rResolution = isset($rStreamJSON['codecs']['video']['height']) ? $rStreamJSON['codecs']['video']['height'] : null; - } - if ($rResolution) { - $rResolution = StreamSorter::getNearest(array(240, 360, 480, 576, 720, 1080, 1440, 2160), $rResolution); - } - } - if ($rStream['pid'] != $rPID) { - $db->query('UPDATE `streams_servers` SET `pid` = ?, `progress_info` = ?, `stream_info` = ?, `compatible` = ?, `bitrate` = ?, `audio_codec` = ?, `video_codec` = ?, `resolution` = ? WHERE `server_stream_id` = ?', $rPID, $rProgress, $rStreamInfo, $rCompatible, $rBitrate, $rAudioCodec, $rVideoCodec, $rResolution, $rStream['server_stream_id']); - } else { - $db->query('UPDATE `streams_servers` SET `progress_info` = ?, `stream_info` = ?, `compatible` = ?, `bitrate` = ?, `audio_codec` = ?, `video_codec` = ?, `resolution` = ? WHERE `server_stream_id` = ?', $rProgress, $rStreamInfo, $rCompatible, $rBitrate, $rAudioCodec, $rVideoCodec, $rResolution, $rStream['server_stream_id']); - } - } - echo "\n"; - } else { - echo 'Start monitor...' . "\n\n"; - if (StreamProcess::startMonitor($rStream['stream_id'], self::handOverNeedsRestart($rStream)) === StreamProcess::MONITOR_PHP) { - usleep(50000); // stagger PHP monitor spawns - } - } - } - } + echo 'Update Stream Information...' . "\n"; + $rBitrate = StreamUtils::getStreamBitrate('live', STREAMS_PATH . $rStream['stream_id'] . '_.m3u8'); + $rProgressPath = STREAMS_PATH . $rStream['stream_id'] . '_.progress'; + if (file_exists($rProgressPath)) { + // ffmpeg appends key=value progress reports to this file + // for the stream's whole life. Read only the tail, keep + // the last COMPLETE report block (terminated by a + // "progress=" line) and re-encode it as the JSON the rest + // of the panel expects. Then truncate so the file stays + // tiny on disk (ffmpeg keeps its write offset, so the + // hole left behind is sparse). + $rTail = ''; + $rFp = fopen($rProgressPath, 'rb'); + if ($rFp !== false) { + fseek($rFp, 0, SEEK_END); + if (ftell($rFp) > 16384) { + fseek($rFp, -16384, SEEK_END); + } else { + rewind($rFp); + } + $rTail = stream_get_contents($rFp); + fclose($rFp); + } + $rReport = []; + $rCurrentReport = []; + foreach (explode("\n", (string) $rTail) as $rLine) { + $rLine = trim($rLine); + if ($rLine === '') { + continue; + } + $rKV = explode('=', $rLine, 2); + if (count($rKV) !== 2) { + continue; + } + $rReportKey = trim($rKV[0]); + $rCurrentReport[$rReportKey] = trim($rKV[1]); + if ($rReportKey === 'progress') { + $rReport = $rCurrentReport; + $rCurrentReport = []; + } + } + $rProgress = $rReport ? json_encode($rReport) : ($rStream['progress_info'] ?: json_encode([])); + file_put_contents($rProgressPath, ''); + if ($rStream['fps_restart']) { + file_put_contents(STREAMS_PATH . $rStream['stream_id'] . '_.progress_check', $rProgress); + } + } else { + $rProgress = $rStream['progress_info']; + } + $rProgress = self::withResourceUsage((string) $rProgress, intval($rStream['stream_id']), $rPID); + // A supervised stream's codecs, resolution and bitrate come + // from the daemon, measured off the bytes (reconcileSupervised + // wrote them above); recomputing them here from a stream_info + // that nothing probes any more would blank them. Only the + // producer's progress report is this pass's to record. + if ($rIsSupervised) { + if ($rProgress !== $rStream['progress_info']) { + $db->query('UPDATE `streams_servers` SET `progress_info` = ? WHERE `server_stream_id` = ?', $rProgress, $rStream['server_stream_id']); + } + echo "\n"; + continue; + } + if (file_exists(STREAMS_PATH . $rStream['stream_id'] . '_.stream_info')) { + $rStreamInfo = file_get_contents(STREAMS_PATH . $rStream['stream_id'] . '_.stream_info'); + unlink(STREAMS_PATH . $rStream['stream_id'] . '_.stream_info'); + } else { + $rStreamInfo = $rStream['stream_info']; + } + $rCompatible = 0; + $rAudioCodec = $rVideoCodec = $rResolution = null; + if ($rStreamInfo) { + $rStreamJSON = json_decode($rStreamInfo, true); + $rCompatible = intval(DiagnosticsService::checkCompatibility($rStreamJSON, SettingsManager::getBool('player_allow_hevc'))); + if (is_array($rStreamJSON) && isset($rStreamJSON['codecs']) && is_array($rStreamJSON['codecs'])) { + $rAudioCodec = isset($rStreamJSON['codecs']['audio']['codec_name']) ? $rStreamJSON['codecs']['audio']['codec_name'] : null; + $rVideoCodec = isset($rStreamJSON['codecs']['video']['codec_name']) ? $rStreamJSON['codecs']['video']['codec_name'] : null; + $rResolution = isset($rStreamJSON['codecs']['video']['height']) ? $rStreamJSON['codecs']['video']['height'] : null; + } + if ($rResolution) { + $rResolution = StreamSorter::getNearest([240, 360, 480, 576, 720, 1080, 1440, 2160], $rResolution); + } + } + if ($rStream['pid'] != $rPID) { + $db->query('UPDATE `streams_servers` SET `pid` = ?, `progress_info` = ?, `stream_info` = ?, `compatible` = ?, `bitrate` = ?, `audio_codec` = ?, `video_codec` = ?, `resolution` = ? WHERE `server_stream_id` = ?', $rPID, $rProgress, $rStreamInfo, $rCompatible, $rBitrate, $rAudioCodec, $rVideoCodec, $rResolution, $rStream['server_stream_id']); + } else { + $db->query('UPDATE `streams_servers` SET `progress_info` = ?, `stream_info` = ?, `compatible` = ?, `bitrate` = ?, `audio_codec` = ?, `video_codec` = ?, `resolution` = ? WHERE `server_stream_id` = ?', $rProgress, $rStreamInfo, $rCompatible, $rBitrate, $rAudioCodec, $rVideoCodec, $rResolution, $rStream['server_stream_id']); + } + } + echo "\n"; + } else { + echo 'Start monitor...' . "\n\n"; + if (StreamProcess::startMonitor($rStream['stream_id'], self::handOverNeedsRestart($rStream)) === StreamProcess::MONITOR_PHP) { + usleep(50000); // stagger PHP monitor spawns + } + } + } + } - $db->query('SELECT `streams`.`id` FROM `streams` LEFT JOIN `streams_servers` ON `streams_servers`.`stream_id` = `streams`.`id` WHERE `streams`.`direct_source` = 1 AND `streams`.`direct_proxy` = 1 AND `streams_servers`.`server_id` = ? AND `streams_servers`.`pid` > 0;', SERVER_ID); - if ($db->num_rows() > 0) { - foreach ($db->get_rows() as $rStream) { - if (file_exists(STREAMS_PATH . $rStream['id'] . '.analyse')) { - $rFFProbeOutput = FFprobeRunner::probeStream(STREAMS_PATH . $rStream['id'] . '.analyse'); - // Defaults: the UPDATE below runs even when probing fails or - // the output has no codec info, so these must always be set. - $rBitrate = $rCompatible = $rAudioCodec = $rVideoCodec = $rResolution = null; - if ($rFFProbeOutput) { - $rBitrate = $rFFProbeOutput['bitrate'] / 1024; - $rCompatible = intval(DiagnosticsService::checkCompatibility($rFFProbeOutput, SettingsManager::getBool('player_allow_hevc'))); - if (is_array($rFFProbeOutput) && isset($rFFProbeOutput['codecs']) && is_array($rFFProbeOutput['codecs'])) { - $rAudioCodec = isset($rFFProbeOutput['codecs']['audio']['codec_name']) ? $rFFProbeOutput['codecs']['audio']['codec_name'] : null; - $rVideoCodec = isset($rFFProbeOutput['codecs']['video']['codec_name']) ? $rFFProbeOutput['codecs']['video']['codec_name'] : null; - $rResolution = isset($rFFProbeOutput['codecs']['video']['height']) ? $rFFProbeOutput['codecs']['video']['height'] : null; - } - if ($rResolution) { - $rResolution = StreamSorter::getNearest(array(240, 360, 480, 576, 720, 1080, 1440, 2160), $rResolution); - } - } - echo 'Stream ID: ' . $rStream['id'] . "\n"; - echo 'Update Stream Information...' . "\n"; - $db->query('UPDATE `streams_servers` SET `bitrate` = ?, `stream_info` = ?, `audio_codec` = ?, `video_codec` = ?, `resolution` = ?, `compatible` = ? WHERE `stream_id` = ? AND `server_id` = ?', $rBitrate, json_encode($rFFProbeOutput), $rAudioCodec, $rVideoCodec, $rResolution, $rCompatible, $rStream['id'], SERVER_ID); - } + $db->query('SELECT `streams`.`id` FROM `streams` LEFT JOIN `streams_servers` ON `streams_servers`.`stream_id` = `streams`.`id` WHERE `streams`.`direct_source` = 1 AND `streams`.`direct_proxy` = 1 AND `streams_servers`.`server_id` = ? AND `streams_servers`.`pid` > 0;', SERVER_ID); + if ($db->num_rows() > 0) { + foreach ($db->get_rows() as $rStream) { + if (file_exists(STREAMS_PATH . $rStream['id'] . '.analyse')) { + $rFFProbeOutput = FFprobeRunner::probeStream(STREAMS_PATH . $rStream['id'] . '.analyse'); + // Defaults: the UPDATE below runs even when probing fails or + // the output has no codec info, so these must always be set. + $rBitrate = $rCompatible = $rAudioCodec = $rVideoCodec = $rResolution = null; + if ($rFFProbeOutput) { + $rBitrate = $rFFProbeOutput['bitrate'] / 1024; + $rCompatible = intval(DiagnosticsService::checkCompatibility($rFFProbeOutput, SettingsManager::getBool('player_allow_hevc'))); + if (is_array($rFFProbeOutput) && isset($rFFProbeOutput['codecs']) && is_array($rFFProbeOutput['codecs'])) { + $rAudioCodec = isset($rFFProbeOutput['codecs']['audio']['codec_name']) ? $rFFProbeOutput['codecs']['audio']['codec_name'] : null; + $rVideoCodec = isset($rFFProbeOutput['codecs']['video']['codec_name']) ? $rFFProbeOutput['codecs']['video']['codec_name'] : null; + $rResolution = isset($rFFProbeOutput['codecs']['video']['height']) ? $rFFProbeOutput['codecs']['video']['height'] : null; + } + if ($rResolution) { + $rResolution = StreamSorter::getNearest([240, 360, 480, 576, 720, 1080, 1440, 2160], $rResolution); + } + } + echo 'Stream ID: ' . $rStream['id'] . "\n"; + echo 'Update Stream Information...' . "\n"; + $db->query('UPDATE `streams_servers` SET `bitrate` = ?, `stream_info` = ?, `audio_codec` = ?, `video_codec` = ?, `resolution` = ?, `compatible` = ? WHERE `stream_id` = ? AND `server_id` = ?', $rBitrate, json_encode($rFFProbeOutput), $rAudioCodec, $rVideoCodec, $rResolution, $rCompatible, $rStream['id'], SERVER_ID); + } - $rUUIDs = array(); - $rConnections = ConnectionTracker::getConnections(SERVER_ID, null, $rStream['id']); - foreach ($rConnections as $rItems) { - foreach ($rItems as $rItem) { - $rUUIDs[] = $rItem['uuid']; - } - } + $rUUIDs = []; + $rConnections = ConnectionTracker::getConnections(SERVER_ID, null, $rStream['id']); + foreach ($rConnections as $rItems) { + foreach ($rItems as $rItem) { + $rUUIDs[] = $rItem['uuid']; + } + } - $rConDir = CONS_TMP_PATH . $rStream['id'] . '/'; - // The per-stream connection dir only exists once a client connects, - // so its absence is normal — guard with is_dir() to avoid a bogus - // opendir() warning being logged every cron tick on idle streams. - if (is_dir($rConDir) && ($rHandle = opendir($rConDir))) { - while (false !== ($rFilename = readdir($rHandle))) { - if ($rFilename != '.' && $rFilename != '..') { - if (!in_array($rFilename, $rUUIDs)) { - unlink(CONS_TMP_PATH . $rStream['id'] . '/' . $rFilename); - } - } - } - closedir($rHandle); - } - } - } + $rConDir = CONS_TMP_PATH . $rStream['id'] . '/'; + // The per-stream connection dir only exists once a client connects, + // so its absence is normal — guard with is_dir() to avoid a bogus + // opendir() warning being logged every cron tick on idle streams. + if (is_dir($rConDir) && ($rHandle = opendir($rConDir))) { + while (false !== ($rFilename = readdir($rHandle))) { + if ($rFilename != '.' && $rFilename != '..') { + if (!in_array($rFilename, $rUUIDs)) { + unlink(CONS_TMP_PATH . $rStream['id'] . '/' . $rFilename); + } + } + } + closedir($rHandle); + } + } + } - $db->query('SELECT `stream_id` FROM `streams_servers` WHERE `on_demand` = 1 AND `server_id` = ?;', SERVER_ID); - $rOnDemandIDs = array_keys($db->get_rows(true, 'stream_id')); - $rProcesses = shell_exec('ps aux | grep XC_VM'); - if (preg_match_all('/XC_VM\\[(.*)\\]/', $rProcesses, $rMatches)) { - $rRemove = array_diff($rMatches[1], $rStreamIDs); - $rRemove = array_diff($rRemove, $rOnDemandIDs); - foreach ($rRemove as $rStreamID) { - if (is_numeric($rStreamID)) { - echo 'Kill Stream ID: ' . $rStreamID . "\n"; - shell_exec("kill -9 `ps -ef | grep '/" . intval($rStreamID) . '_.m3u8\\|XC_VM\\[' . intval($rStreamID) . "\\]' | grep -v grep | awk '{print \$2}'`;"); - shell_exec('rm -f ' . STREAMS_PATH . intval($rStreamID) . '_*'); - } - } - } + $db->query('SELECT `stream_id` FROM `streams_servers` WHERE `on_demand` = 1 AND `server_id` = ?;', SERVER_ID); + $rOnDemandIDs = array_keys($db->get_rows(true, 'stream_id')); + $rProcesses = shell_exec('ps aux | grep XC_VM'); + if (preg_match_all('/XC_VM\\[(.*)\\]/', $rProcesses, $rMatches)) { + $rRemove = array_diff($rMatches[1], $rStreamIDs); + $rRemove = array_diff($rRemove, $rOnDemandIDs); + foreach ($rRemove as $rStreamID) { + if (is_numeric($rStreamID)) { + echo 'Kill Stream ID: ' . $rStreamID . "\n"; + shell_exec("kill -9 `ps -ef | grep '/" . intval($rStreamID) . '_.m3u8\\|XC_VM\\[' . intval($rStreamID) . "\\]' | grep -v grep | awk '{print \$2}'`;"); + shell_exec('rm -f ' . STREAMS_PATH . intval($rStreamID) . '_*'); + } + } + } - if (SettingsManager::getBool('kill_rogue_ffmpeg')) { - // The supervisor restarts producers on its own schedule: a pid read from - // _.pid at the top of this pass may already have been replaced, and the - // replacement is not rogue. Ask the daemon for what it runs NOW. - $rStates = FanoutClient::monitorStates(); - foreach (($rStates['streams'] ?? array()) as $rState) { - if (intval($rState['pid'] ?? 0) > 0) { - $rActivePIDs[] = intval($rState['pid']); - } - } - exec("ps aux | grep -v grep | grep '/*_.m3u8' | awk '{print \$2}'", $rRoguePIDs); - foreach ($rRoguePIDs as $rPID) { - if (is_numeric($rPID) && intval($rPID) > 0 && !in_array($rPID, $rActivePIDs)) { - echo 'Kill Roque PID: ' . $rPID . "\n"; - shell_exec('kill -9 ' . $rPID . ';'); - } - } - } - } + if (SettingsManager::getBool('kill_rogue_ffmpeg')) { + // The supervisor restarts producers on its own schedule: a pid read from + // _.pid at the top of this pass may already have been replaced, and the + // replacement is not rogue. Ask the daemon for what it runs NOW. + $rStates = FanoutClient::monitorStates(); + foreach (($rStates['streams'] ?? []) as $rState) { + if (intval($rState['pid'] ?? 0) > 0) { + $rActivePIDs[] = intval($rState['pid']); + } + } + exec("ps aux | grep -v grep | grep '/*_.m3u8' | awk '{print \$2}'", $rRoguePIDs); + foreach ($rRoguePIDs as $rPID) { + if (is_numeric($rPID) && intval($rPID) > 0 && !in_array($rPID, $rActivePIDs)) { + echo 'Kill Roque PID: ' . $rPID . "\n"; + shell_exec('kill -9 ' . $rPID . ';'); + } + } + } + } } diff --git a/src/Cli/CronJobs/StreamsLogsCronJob.php b/src/Cli/CronJobs/StreamsLogsCronJob.php index 69a56e3d..60d8746f 100644 --- a/src/Cli/CronJobs/StreamsLogsCronJob.php +++ b/src/Cli/CronJobs/StreamsLogsCronJob.php @@ -16,58 +16,58 @@ use XcVm\Cli\CronTrait; */ class StreamsLogsCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:streams_logs'; - } + public function getName(): string { + return 'cron:streams_logs'; + } - public function getDescription(): string { - return 'Cron: import stream logs into DB'; - } + public function getDescription(): string { + return 'Cron: import stream logs into DB'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->initCron('XC_VM[Stream Logs]'); + $this->initCron('XC_VM[Stream Logs]'); - global $db; + global $db; - $rLog = LOGS_TMP_PATH . 'stream_log.log'; - if (!file_exists($rLog)) { - return 0; - } + $rLog = LOGS_TMP_PATH . 'stream_log.log'; + if (!file_exists($rLog)) { + return 0; + } - $rQuery = rtrim($this->parseLog($rLog), ','); - if (!empty($rQuery)) { - $db->query('INSERT INTO `streams_logs` (`stream_id`,`server_id`,`action`,`source`,`date`) VALUES ' . $rQuery . ';'); - } - unlink($rLog); + $rQuery = rtrim($this->parseLog($rLog), ','); + if (!empty($rQuery)) { + $db->query('INSERT INTO `streams_logs` (`stream_id`,`server_id`,`action`,`source`,`date`) VALUES ' . $rQuery . ';'); + } + unlink($rLog); - return 0; - } + return 0; + } - private function parseLog(string $rLog): string { - $rQuery = ''; - if (!file_exists($rLog)) { - return $rQuery; - } + private function parseLog(string $rLog): string { + $rQuery = ''; + if (!file_exists($rLog)) { + return $rQuery; + } - $rFP = fopen($rLog, 'r'); - while (!feof($rFP)) { - $rLine = trim(fgets($rFP)); - if (!empty($rLine)) { - $rLine = json_decode(base64_decode($rLine), true); - if (!$rLine['stream_id']) { - continue; - } - $rQuery .= '(' . intval($rLine['stream_id']) . ',' . SERVER_ID . ",'" . addslashes($rLine['action']) . "','" . addslashes($rLine['source']) . "','" . addslashes($rLine['time']) . "'),"; - } - } - fclose($rFP); + $rFP = fopen($rLog, 'r'); + while (!feof($rFP)) { + $rLine = trim(fgets($rFP)); + if (!empty($rLine)) { + $rLine = json_decode(base64_decode($rLine), true); + if (!$rLine['stream_id']) { + continue; + } + $rQuery .= '(' . intval($rLine['stream_id']) . ',' . SERVER_ID . ",'" . addslashes($rLine['action']) . "','" . addslashes($rLine['source']) . "','" . addslashes($rLine['time']) . "'),"; + } + } + fclose($rFP); - return $rQuery; - } + return $rQuery; + } } diff --git a/src/Cli/CronJobs/TmdbCronJob.php b/src/Cli/CronJobs/TmdbCronJob.php index bea77a98..a7580486 100644 --- a/src/Cli/CronJobs/TmdbCronJob.php +++ b/src/Cli/CronJobs/TmdbCronJob.php @@ -18,31 +18,31 @@ use XcVm\Infrastructure\Tmdb\TmdbApiService; */ class TmdbCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:tmdb'; - } + public function getName(): string { + return 'cron:tmdb'; + } - public function getDescription(): string { - return 'Cron: update TMDB data (series, movies)'; - } + public function getDescription(): string { + return 'Cron: update TMDB data (series, movies)'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - TmdbApiService::requireLibrary(); + TmdbApiService::requireLibrary(); - $this->initCron('XC_VM[TMDB]'); + $this->initCron('XC_VM[TMDB]'); - $rTimeout = 3600; - set_time_limit($rTimeout); - ini_set('max_execution_time', $rTimeout); + $rTimeout = 3600; + set_time_limit($rTimeout); + ini_set('max_execution_time', $rTimeout); - TmdbCron::run(); + TmdbCron::run(); - return 0; - } + return 0; + } } diff --git a/src/Cli/CronJobs/TmdbPopularCronJob.php b/src/Cli/CronJobs/TmdbPopularCronJob.php index d80c0abe..5369ee00 100644 --- a/src/Cli/CronJobs/TmdbPopularCronJob.php +++ b/src/Cli/CronJobs/TmdbPopularCronJob.php @@ -18,27 +18,27 @@ use XcVm\Infrastructure\Tmdb\TmdbApiService; */ class TmdbPopularCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:tmdb_popular'; - } + public function getName(): string { + return 'cron:tmdb_popular'; + } - public function getDescription(): string { - return 'Cron: update popular TMDB movies'; - } + public function getDescription(): string { + return 'Cron: update popular TMDB movies'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->initCron('XC_VM[Popular]'); + $this->initCron('XC_VM[Popular]'); - TmdbApiService::requireLibrary(); + TmdbApiService::requireLibrary(); - TmdbPopularCron::run(); + TmdbPopularCron::run(); - return 0; - } + return 0; + } } diff --git a/src/Cli/CronJobs/TmpCronJob.php b/src/Cli/CronJobs/TmpCronJob.php index 69f6ad38..13e31891 100644 --- a/src/Cli/CronJobs/TmpCronJob.php +++ b/src/Cli/CronJobs/TmpCronJob.php @@ -17,63 +17,63 @@ use XcVm\Core\Config\SettingsManager; */ class TmpCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:tmp'; - } + public function getName(): string { + return 'cron:tmp'; + } - public function getDescription(): string { - return 'Cron: cleanup temporary files and stale playlists'; - } + public function getDescription(): string { + return 'Cron: cleanup temporary files and stale playlists'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - global $db; - $db->close_mysql(); + global $db; + $db->close_mysql(); - $this->setProcessTitle('XC_VM[TMP]'); - $this->acquireCronLock(); + $this->setProcessTitle('XC_VM[TMP]'); + $this->acquireCronLock(); - $rTmpPaths = array( - TMP_PATH, CRONS_TMP_PATH, DIVERGENCE_TMP_PATH, - FLOOD_TMP_PATH, MINISTRA_TMP_PATH, SIGNALS_TMP_PATH, LOGS_TMP_PATH - ); + $rTmpPaths = [ + TMP_PATH, CRONS_TMP_PATH, DIVERGENCE_TMP_PATH, + FLOOD_TMP_PATH, MINISTRA_TMP_PATH, SIGNALS_TMP_PATH, LOGS_TMP_PATH + ]; - foreach ($rTmpPaths as $rTmpPath) { - if (!is_dir($rTmpPath)) { - @mkdir($rTmpPath, 0775, true); - continue; - } - foreach (scandir($rTmpPath) as $rFile) { - $fullPath = $rTmpPath . '/' . $rFile; - if ($rFile === '.' || $rFile === '..') { - continue; - } - if (is_file($fullPath) && time() - filemtime($fullPath) >= 600 && stripos($rFile, 'ministra_') === false) { - unlink($fullPath); - } - } - } + foreach ($rTmpPaths as $rTmpPath) { + if (!is_dir($rTmpPath)) { + @mkdir($rTmpPath, 0775, true); + continue; + } + foreach (scandir($rTmpPath) as $rFile) { + $fullPath = $rTmpPath . '/' . $rFile; + if ($rFile === '.' || $rFile === '..') { + continue; + } + if (is_file($fullPath) && time() - filemtime($fullPath) >= 600 && stripos($rFile, 'ministra_') === false) { + unlink($fullPath); + } + } + } - foreach (scandir(PLAYLIST_PATH) as $rFile) { - $fullPath = rtrim(PLAYLIST_PATH, '/') . '/' . $rFile; - if ($rFile === '.' || $rFile === '..') { - continue; - } - if (is_file($fullPath)) { - if (SettingsManager::get('cache_playlists') <= time() - filemtime($fullPath)) { - unlink($fullPath); - } - } - } + foreach (scandir(PLAYLIST_PATH) as $rFile) { + $fullPath = rtrim(PLAYLIST_PATH, '/') . '/' . $rFile; + if ($rFile === '.' || $rFile === '..') { + continue; + } + if (is_file($fullPath)) { + if (SettingsManager::get('cache_playlists') <= time() - filemtime($fullPath)) { + unlink($fullPath); + } + } + } - clearstatcache(); - @unlink($this->rIdentifier); + clearstatcache(); + @unlink($this->rIdentifier); - return 0; - } + return 0; + } } diff --git a/src/Cli/CronJobs/UpdateCronJob.php b/src/Cli/CronJobs/UpdateCronJob.php index e4f7e9ae..8e843bdc 100644 --- a/src/Cli/CronJobs/UpdateCronJob.php +++ b/src/Cli/CronJobs/UpdateCronJob.php @@ -19,68 +19,68 @@ use XcVm\Core\Updates\UpdateChannels; */ class UpdateCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:update'; - } + public function getName(): string { + return 'cron:update'; + } - public function getDescription(): string { - return 'Cron: check for XC_VM updates'; - } + public function getDescription(): string { + return 'Cron: check for XC_VM updates'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - if (!$this->isRunning()) { - return 1; - } + if (!$this->isRunning()) { + return 1; + } - global $db, $gitRelease; + global $db, $gitRelease; - if (!$gitRelease) { - if (defined('GIT_OWNER') && defined('GIT_REPO_MAIN')) { - $gitRelease = new GitHubReleases(GIT_OWNER, GIT_REPO_MAIN, UpdateChannels::main()); - } - } + if (!$gitRelease) { + if (defined('GIT_OWNER') && defined('GIT_REPO_MAIN')) { + $gitRelease = new GitHubReleases(GIT_OWNER, GIT_REPO_MAIN, UpdateChannels::main()); + } + } - if (!$gitRelease) { - FileLogger::log('cron', 'GitRelease service not initialized', 'cron:update'); - return 1; - } + if (!$gitRelease) { + FileLogger::log('cron', 'GitRelease service not initialized', 'cron:update'); + return 1; + } - $rUpdate = $gitRelease->getUpdate(XC_VM_VERSION); + $rUpdate = $gitRelease->getUpdate(XC_VM_VERSION); - if (is_array($rUpdate) && $rUpdate['version'] && (0 < version_compare($rUpdate['version'], XC_VM_VERSION) || version_compare($rUpdate['version'], XC_VM_VERSION) == 0)) { - echo 'Update is available!' . "\n"; - $updatedChanges = array(); - foreach (array_reverse($rUpdate['changelog']) as $rItem) { - if (!($rItem['version'] == XC_VM_VERSION)) { - $updatedChanges[] = $rItem; - } else { - break; - } - } - $rUpdate['changelog'] = $updatedChanges; - $db->query('UPDATE `settings` SET `update_data` = ?;', json_encode($rUpdate)); - } else { - $db->query('UPDATE `settings` SET `update_data` = NULL;'); - } + if (is_array($rUpdate) && $rUpdate['version'] && (0 < version_compare($rUpdate['version'], XC_VM_VERSION) || version_compare($rUpdate['version'], XC_VM_VERSION) == 0)) { + echo 'Update is available!' . "\n"; + $updatedChanges = []; + foreach (array_reverse($rUpdate['changelog']) as $rItem) { + if (!($rItem['version'] == XC_VM_VERSION)) { + $updatedChanges[] = $rItem; + } else { + break; + } + } + $rUpdate['changelog'] = $updatedChanges; + $db->query('UPDATE `settings` SET `update_data` = ?;', json_encode($rUpdate)); + } else { + $db->query('UPDATE `settings` SET `update_data` = NULL;'); + } - return 0; - } + return 0; + } - private function isRunning(): bool { - $rNginx = 0; - exec('ps -fp $(pgrep -u xc_vm)', $rOutput, $rReturnVar); - foreach ($rOutput as $rProcess) { - $rSplit = explode(' ', preg_replace('!\\s+!', ' ', trim($rProcess))); - if ($rSplit[8] == 'nginx:' && $rSplit[9] == 'master') { - $rNginx++; - } - } - return 0 < $rNginx; - } + private function isRunning(): bool { + $rNginx = 0; + exec('ps -fp $(pgrep -u xc_vm)', $rOutput, $rReturnVar); + foreach ($rOutput as $rProcess) { + $rSplit = explode(' ', preg_replace('!\\s+!', ' ', trim($rProcess))); + if ($rSplit[8] == 'nginx:' && $rSplit[9] == 'master') { + $rNginx++; + } + } + return 0 < $rNginx; + } } diff --git a/src/Cli/CronJobs/UsersCronJob.php b/src/Cli/CronJobs/UsersCronJob.php index 3b7e5ef3..01cf7517 100644 --- a/src/Cli/CronJobs/UsersCronJob.php +++ b/src/Cli/CronJobs/UsersCronJob.php @@ -22,620 +22,621 @@ use XcVm\Infrastructure\Redis\RedisManager; */ class UsersCronJob implements CommandInterface { - use CronTrait; - - private $rPHPPIDs = array(); - private $rServers = array(); - - public function getName(): string { - return 'cron:users'; - } - - public function getDescription(): string { - return 'Cron: manage user connections, Redis sync, divergence'; - } - - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } - - set_time_limit(0); - ini_set('memory_limit', -1); - - $this->setProcessTitle('XC_VM[Users]'); - $this->acquireCronLock(); - - global $db; - - $rSync = null; - $this->rServers = ServerRepository::getAll(); - - if (!empty($rArgs[0]) && $this->rServers[SERVER_ID]['is_main']) { - RedisManager::ensureConnected(); - - if (RedisManager::isConnected()) { - $rSync = intval($rArgs[0]); - - if ($rSync == 1) { - $rDeSync = $rRedisUsers = $rRedisUpdate = $rRedisSet = array(); - $db->query('SELECT * FROM `lines_live` WHERE `hls_end` = 0;'); - $rRows = $db->get_rows(); - - if (count($rRows) > 0) { - $rStreamIDs = array(); - - foreach ($rRows as $rRow) { - $streamId = (int)$rRow['stream_id']; - if ($streamId > 0 && !in_array($streamId, $rStreamIDs)) { - $rStreamIDs[] = $streamId; - } - } - - $rOnDemand = array(); - if (count($rStreamIDs) > 0) { - $db->query('SELECT `stream_id`, `server_id`, `on_demand` FROM `streams_servers` WHERE `stream_id` IN (' . implode(',', $rStreamIDs) . ');'); - foreach ($db->get_rows() as $rRow) { - $rOnDemand[$rRow['stream_id']][$rRow['server_id']] = intval($rRow['on_demand']); - } - } - - $rRedis = RedisManager::instance()->multi(); - - foreach ($rRows as $rRow) { - echo 'Resynchronising UUID: ' . $rRow['uuid'] . "\n"; - - if (empty($rRow['hmac_id'])) { - $rRow['identity'] = $rRow['user_id']; - } else { - $rRow['identity'] = $rRow['hmac_id'] . '_' . $rRow['hmac_identifier']; - } - - $rRow['on_demand'] = ($rOnDemand[$rRow['stream_id']][$rRow['server_id']] ?: 0); - $rRedis->zAdd('LINE#' . $rRow['identity'], $rRow['date_start'], $rRow['uuid']); - $rRedis->zAdd('LINE_ALL#' . $rRow['identity'], $rRow['date_start'], $rRow['uuid']); - $rRedis->zAdd('STREAM#' . $rRow['stream_id'], $rRow['date_start'], $rRow['uuid']); - $rRedis->zAdd('SERVER#' . $rRow['server_id'], $rRow['date_start'], $rRow['uuid']); - - if ($rRow['user_id']) { - $rRedis->zAdd('SERVER_LINES#' . $rRow['server_id'], $rRow['user_id'], $rRow['uuid']); - } - - if ($rRow['proxy_id']) { - $rRedis->zAdd('PROXY#' . $rRow['proxy_id'], $rRow['date_start'], $rRow['uuid']); - } - - $rRedis->zAdd('CONNECTIONS', $rRow['date_start'], $rRow['uuid']); - $rRedis->zAdd('LIVE', $rRow['date_start'], $rRow['uuid']); - $rRedis->set($rRow['uuid'], igbinary_serialize($rRow)); - $rDeSync[] = $rRow['uuid']; - } - $rRedis->exec(); - - if (count($rDeSync) > 0) { - $db->query("DELETE FROM `lines_live` WHERE `uuid` IN ('" . implode("','", $rDeSync) . "');"); - } - } - } - } else { - echo "Couldn't connect to Redis.\n"; - return 1; - } - } - - if (SettingsManager::getBool('redis_handler') && $this->rServers[SERVER_ID]['is_main']) { - $this->rServers = ServerRepository::getAll(true); - - foreach ($this->rServers as $rServer) { - $rDecodedPids = json_decode($rServer['php_pids'] ?? '', true); - $this->rPHPPIDs[$rServer['id']] = is_array($rDecodedPids) ? array_map('intval', $rDecodedPids) : []; - } - } - - $this->loadCron(); - - return 0; - } - - private function processDeletions($rDelete, $rDelStream = array()) { - $rRedis = SettingsManager::getBool('redis_handler'); - global $db; - $rTime = time(); - - if ($rRedis) { - // Redis can die mid-run — postpone cleanup instead of crashing the - // cron; the entries stay in LIVE/ENDED and are re-detected next run. - if ($rDelete['count'] > 0 && ($rRedisInstance = RedisManager::instance())) { - $rRedis = $rRedisInstance->multi(); - - foreach ($rDelete['line'] as $rUserID => $rUUIDs) { - $rRedis->zRem('LINE#' . $rUserID, ...$rUUIDs); - $rRedis->zRem('LINE_ALL#' . $rUserID, ...$rUUIDs); - } - - foreach ($rDelete['stream'] as $rStreamID => $rUUIDs) { - $rRedis->zRem('STREAM#' . $rStreamID, ...$rUUIDs); - } - - foreach ($rDelete['server'] as $rServerID => $rUUIDs) { - $rRedis->zRem('SERVER#' . $rServerID, ...$rUUIDs); - $rRedis->zRem('SERVER_LINES#' . $rServerID, ...$rUUIDs); - } - - foreach ($rDelete['proxy'] as $rProxyID => $rUUIDs) { - $rRedis->zRem('PROXY#' . $rProxyID, ...$rUUIDs); - } - - if (count($rDelete['uuid']) > 0) { - $rRedis->zRem('CONNECTIONS', ...$rDelete['uuid']); - $rRedis->zRem('LIVE', ...$rDelete['uuid']); - $rRedis->sRem('ENDED', ...$rDelete['uuid']); - $rRedis->del(...$rDelete['uuid']); - } - - $rRedis->exec(); - } elseif ($rDelete['count'] > 0) { - echo "Redis unavailable, connection cleanup postponed until next run\n"; - } - } else { - foreach ($rDelete as $rServerID => $rConnections) { - if (count($rConnections) > 0) { - $db->query("DELETE FROM `lines_live` WHERE `uuid` IN ('" . implode("','", $rConnections) . "')"); - } - } - } - - foreach (($rRedis ? $rDelete['server'] : $rDelete) as $rServerID => $rConnections) { - if ($rServerID != SERVER_ID) { - $rQuery = ''; - - foreach ($rConnections as $rConnection) { - $rQuery .= '(' . $rServerID . ',1,' . $rTime . ',' . $db->escape(json_encode(array('type' => 'delete_con', 'uuid' => $rConnection))) . '),'; - } - $rQuery = rtrim($rQuery, ','); - - if (!empty($rQuery)) { - $db->query('INSERT INTO `signals`(`server_id`, `cache`, `time`, `custom_data`) VALUES ' . $rQuery . ';'); - } - } - } - - foreach ($rDelStream as $rStreamID => $rConnections) { - foreach ($rConnections as $rConnection) { - @unlink(CONS_TMP_PATH . $rStreamID . '/' . $rConnection); - } - } - - if ($rRedis) { - return array('line' => array(), 'server' => array(), 'server_lines' => array(), 'proxy' => array(), 'stream' => array(), 'uuid' => array(), 'count' => 0); - } - - return array(); - } - - /** - * Blob 'identity' may be absent (written by an external component or a - * pre-identity build) — derive it the same way the sync path does. - * - * @param array $rConnection Deserialized connection blob. - * @return int|string - */ - private function connectionIdentity(array $rConnection) { - if (isset($rConnection['identity'])) { - return $rConnection['identity']; - } - - if (empty($rConnection['hmac_id'])) { - return intval($rConnection['user_id'] ?? 0); - } - - return $rConnection['hmac_id'] . '_' . ($rConnection['hmac_identifier'] ?? ''); - } - - private function loadCron(): void { - $rRedis = SettingsManager::getBool('redis_handler'); - global $db; - - $rServers = $this->rServers; - $rPHPPIDs = $this->rPHPPIDs; - - if ($rRedis) { - RedisManager::ensureConnected(); - } - - $rStartTime = time(); - $rLiveKeys = array(); - - if (!$rRedis || $rServers[SERVER_ID]['is_main']) { - $rAutoKick = SettingsManager::getInt('user_auto_kick_hours') * 3600; - $rLiveKeys = $rDelete = $rDeleteStream = array(); - $rRedisDelete = array('line' => array(), 'server' => array(), 'server_lines' => array(), 'proxy' => array(), 'stream' => array(), 'uuid' => array(), 'count' => 0); - - if ($rRedis) { - $rUsers = array(); - $rResult = ConnectionTracker::getConnections(); - $rKeys = $rResult[0] ?? []; - $rConnections = $rResult[1] ?? []; - $i = 0; - - for ($rSize = count($rConnections); $i < $rSize; $i++) { - $rConnection = $rConnections[$i]; - - if (is_array($rConnection)) { - $rConnection['identity'] = $this->connectionIdentity($rConnection); - $rUsers[$rConnection['identity']][] = $rConnection; - $rLiveKeys[] = $rConnection['uuid']; - } else { - $rRedisDelete['count']++; - $rRedisDelete['uuid'][] = $rKeys[$i]; - } - } - unset($rConnections); - } else { - $rUsers = ConnectionTracker::getConnections(($rServers[SERVER_ID]['is_main'] ? null : SERVER_ID)); - } - - $rRestreamerArray = $rMaxConnectionsArray = array(); - $rUserIDs = InputValidator::confirmIDs(array_keys($rUsers)); - - if (count($rUserIDs) > 0) { - $db->query('SELECT `id`, `max_connections`, `is_restreamer` FROM `lines` WHERE `id` IN (' . implode(',', $rUserIDs) . ');'); - - foreach ($db->get_rows() as $rRow) { - $rMaxConnectionsArray[$rRow['id']] = $rRow['max_connections']; - $rRestreamerArray[$rRow['id']] = $rRow['is_restreamer']; - } - } - - if ($rRedis && $rServers[SERVER_ID]['is_main']) { - foreach (ConnectionTracker::getEnded() as $rConnection) { - if (is_array($rConnection)) { - $rConnection['identity'] = $this->connectionIdentity($rConnection); - if (!in_array($rConnection['container'], array('ts', 'hls', 'rtmp')) && time() - $rConnection['hls_last_read'] < 300) { - $rClose = false; - } else { - $rClose = true; - } - - if ($rClose) { - echo 'Close connection: ' . $rConnection['uuid'] . "\n"; - ConnectionTracker::closeConnection($rConnection, false, false); - $rRedisDelete['count']++; - $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; - $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; - $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; - $rRedisDelete['uuid'][] = $rConnection['uuid']; - - if ($rConnection['proxy_id']) { - $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; - } - } - } - } - - if ($rRedisDelete['count'] >= 1000) { - $rRedisDelete = $this->processDeletions($rRedisDelete, $rRedisDelete['stream']); - } - } - - foreach ($rUsers as $rUserID => $rConnections) { - $rActiveCount = 0; - // HMAC identities and deleted lines have no row in `lines` — - // 0 disables the max-connections kick below. - $rMaxConnections = $rMaxConnectionsArray[$rUserID] ?? 0; - $rIsRestreamer = !empty($rRestreamerArray[$rUserID]); - - foreach ($rConnections as $rConnection) { - if ($rConnection['server_id'] == SERVER_ID || $rRedis) { - if (!isset($rConnection['exp_date']) || is_null($rConnection['exp_date']) || $rConnection['exp_date'] >= $rStartTime) { - $rTotalTime = $rStartTime - $rConnection['date_start']; - - if (!($rAutoKick != 0 && $rAutoKick <= $rTotalTime) || $rIsRestreamer) { - if ($rConnection['container'] == 'hls') { - if (30 <= $rStartTime - $rConnection['hls_last_read'] || $rConnection['hls_end'] == 1) { - echo 'Close connection: ' . $rConnection['uuid'] . "\n"; - ConnectionTracker::closeConnection($rConnection, false, false); - - if ($rRedis) { - $rRedisDelete['count']++; - $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; - $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; - $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; - $rRedisDelete['uuid'][] = $rConnection['uuid']; - - if ($rConnection['user_id']) { - $rRedisDelete['server_lines'][$rConnection['server_id']][] = $rConnection['uuid']; - } - - if ($rConnection['proxy_id']) { - $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; - } - } else { - $rDelete[$rConnection['server_id']][] = $rConnection['uuid']; - $rDeleteStream[$rConnection['stream_id']] = $rDelete[$rConnection['server_id']]; - } - } - } else { - // Daemon-served TS (pid=0, ADR 0003 Phase C) has no PHP - // worker to probe — the fanout_sync daemon closes it by - // reconciling against the daemon's live-connection set. - // Skip it here so this reaper neither leaks it (a live - // reused worker pid reads as "running") nor closes it - // early (when that worker recycles). - if ($rConnection['container'] != 'rtmp' && intval($rConnection['pid']) !== 0) { - if ($rConnection['server_id'] == SERVER_ID) { - $rIsRunning = ProcessManager::isRunning($rConnection['pid'], 'php-fpm'); - } else { - if ($rConnection['date_start'] <= $rServers[$rConnection['server_id']]['last_check_ago'] - 1 && 0 < count($rPHPPIDs[$rConnection['server_id']])) { - $rIsRunning = in_array(intval($rConnection['pid']), $rPHPPIDs[$rConnection['server_id']]); - } else { - $rIsRunning = true; - } - } - - if (($rConnection['hls_end'] == 1 && ($rStartTime - $rConnection['hls_last_read']) >= 300) || !$rIsRunning) { - echo 'Close connection: ' . $rConnection['uuid'] . "\n"; - ConnectionTracker::closeConnection($rConnection, false, false); - - if ($rRedis) { - $rRedisDelete['count']++; - $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; - $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; - $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; - $rRedisDelete['uuid'][] = $rConnection['uuid']; - - if ($rConnection['user_id']) { - $rRedisDelete['server_lines'][$rConnection['server_id']][] = $rConnection['uuid']; - } - - if ($rConnection['proxy_id']) { - $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; - } - } else { - $rDelete[$rConnection['server_id']][] = $rConnection['uuid']; - $rDeleteStream[$rConnection['stream_id']] = $rDelete[$rConnection['server_id']]; - } - } - } - } - } else { - echo 'Close connection: ' . $rConnection['uuid'] . "\n"; - ConnectionTracker::closeConnection($rConnection, false, false); - - if ($rRedis) { - $rRedisDelete['count']++; - $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; - $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; - $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; - $rRedisDelete['uuid'][] = $rConnection['uuid']; - - if ($rConnection['user_id']) { - $rRedisDelete['server_lines'][$rConnection['server_id']][] = $rConnection['uuid']; - } - - if ($rConnection['proxy_id']) { - $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; - } - } else { - $rDelete[$rConnection['server_id']][] = $rConnection['uuid']; - $rDeleteStream[$rConnection['stream_id']] = $rDelete[$rConnection['server_id']]; - } - } - } else { - echo 'Close connection: ' . $rConnection['uuid'] . "\n"; - ConnectionTracker::closeConnection($rConnection, false, false); - - if ($rRedis) { - $rRedisDelete['count']++; - $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; - $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; - $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; - $rRedisDelete['uuid'][] = $rConnection['uuid']; - - if ($rConnection['user_id']) { - $rRedisDelete['server_lines'][$rConnection['server_id']][] = $rConnection['uuid']; - } - - if ($rConnection['proxy_id']) { - $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; - } - } else { - $rDelete[$rConnection['server_id']][] = $rConnection['uuid']; - $rDeleteStream[$rConnection['stream_id']] = $rDelete[$rConnection['server_id']]; - } - } - } - - if (!$rConnection['hls_end']) { - $rActiveCount++; - } - } - - if ($rServers[SERVER_ID]['is_main'] && 0 < $rMaxConnections && $rMaxConnections < $rActiveCount) { - foreach ($rConnections as $rConnection) { - if (!$rConnection['hls_end']) { - echo 'Close connection: ' . $rConnection['uuid'] . "\n"; - ConnectionTracker::closeConnection($rConnection, false, false); - - if ($rRedis) { - $rRedisDelete['count']++; - $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; - $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; - $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; - $rRedisDelete['uuid'][] = $rConnection['uuid']; - - if ($rConnection['user_id']) { - $rRedisDelete['server_lines'][$rConnection['server_id']][] = $rConnection['uuid']; - } - - if ($rConnection['proxy_id']) { - $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; - } - } else { - $rDelete[$rConnection['server_id']][] = $rConnection['uuid']; - $rDeleteStream[$rConnection['stream_id']] = $rDelete[$rConnection['server_id']]; - } - - $rActiveCount--; - } - - if ($rActiveCount >= $rMaxConnections) { - break; - } - } - } - - if ($rRedis && 1000 <= $rRedisDelete['count']) { - $rRedisDelete = $this->processDeletions($rRedisDelete, $rRedisDelete['stream']); - } else { - if (!$rRedis && count($rDelete) >= 1000) { - $rDelete = $this->processDeletions($rDelete, $rDeleteStream); - } - } - } - - if ($rRedis && 0 < $rRedisDelete['count']) { - $this->processDeletions($rRedisDelete, $rRedisDelete['stream']); - } else { - if (!$rRedis && count($rDelete) > 0) { - $this->processDeletions($rDelete, $rDeleteStream); - } - } - } - - $rConnectionSpeeds = glob(DIVERGENCE_TMP_PATH . '*'); - - if (count($rConnectionSpeeds) > 0) { - $rBitrates = []; - - if ($rRedis) { - $rStreamMap = []; - - $db->query('SELECT `stream_id`, `bitrate` FROM `streams_servers` WHERE `server_id` = ? AND `bitrate` IS NOT NULL;', SERVER_ID); - foreach ($db->get_rows() as $rRow) { - $bitrate = intval($rRow['bitrate']); - if ($bitrate > 0) { - $rStreamMap[intval($rRow['stream_id'])] = intval($bitrate / 8 * 0.92); - } - } - - $rUUIDs = []; - foreach ($rConnectionSpeeds as $rConnectionSpeed) { - if (!empty($rConnectionSpeed)) { - $rUUIDs[] = basename($rConnectionSpeed); - } - } - - if (count($rUUIDs) > 0) { - $rRedis = RedisManager::instance(); - if (!$rRedis) { - $rConnections = array(); - } else { - $rConnections = array_map( - static fn($v) => ($v !== false) ? igbinary_unserialize($v) : null, - $rRedis->mGet($rUUIDs) - ); - } - - foreach ($rConnections as $rConnection) { - if (!is_array($rConnection)) { - continue; - } - - $uuid = $rConnection['uuid']; - $streamId = intval($rConnection['stream_id']); - - if (!isset($rStreamMap[$streamId])) { - continue; - } - - $rBitrates[$uuid] = $rStreamMap[$streamId]; - } - } - - unset($rStreamMap); - } else { - $db->query('SELECT `lines_live`.`uuid`, `streams_servers`.`bitrate` FROM `lines_live` LEFT JOIN `streams_servers` ON `lines_live`.`stream_id` = `streams_servers`.`stream_id` AND `lines_live`.`server_id` = `streams_servers`.`server_id` WHERE `lines_live`.`server_id` = ?;', SERVER_ID); - - foreach ($db->get_rows() as $rRow) { - $bitrate = intval($rRow['bitrate']); - if ($bitrate > 0) { - $rBitrates[$rRow['uuid']] = intval($bitrate / 8 * 0.92); - } - } - } - - if (!$rRedis) { - $rUUIDMap = array(); - $db->query('SELECT `uuid`, `activity_id` FROM `lines_live`;'); - foreach ($db->get_rows() as $rRow) { - $rUUIDMap[$rRow['uuid']] = $rRow['activity_id']; - } - } - - $rLiveQuery = $rDivergenceUpdate = []; - - foreach ($rConnectionSpeeds as $rConnectionSpeed) { - if (empty($rConnectionSpeed)) { - continue; - } - - $rUUID = basename($rConnectionSpeed); - $rAverageSpeed = intval(file_get_contents($rConnectionSpeed)); - - if (!isset($rBitrates[$rUUID]) || $rBitrates[$rUUID] <= 0) { - $rDivergenceUpdate[] = "('" . $rUUID . "', 0)"; - - if (!$rRedis && isset($rUUIDMap[$rUUID])) { - $rLiveQuery[] = '(' . $rUUIDMap[$rUUID] . ', 0)'; - } - - continue; - } - - $realBitrate = $rBitrates[$rUUID]; - $rDivergence = intval(($rAverageSpeed - $realBitrate) / $realBitrate * 100); - - if ($rDivergence > 0) { - $rDivergence = 0; - } - - $rDivergenceUpdate[] = "('" . $rUUID . "', " . abs($rDivergence) . ')'; - - if (!$rRedis && isset($rUUIDMap[$rUUID])) { - $rLiveQuery[] = '(' . $rUUIDMap[$rUUID] . ', ' . abs($rDivergence) . ')'; - } - } - - if (count($rDivergenceUpdate) > 0) { - $rUpdateQuery = implode(',', $rDivergenceUpdate); - $db->query('INSERT INTO `lines_divergence`(`uuid`,`divergence`) VALUES ' . $rUpdateQuery . ' ON DUPLICATE KEY UPDATE `divergence`=VALUES(`divergence`);'); - } - - if (!$rRedis && count($rLiveQuery) > 0) { - $rLiveQueryStr = implode(',', $rLiveQuery); - $db->query('INSERT INTO `lines_live`(`activity_id`,`divergence`) VALUES ' . $rLiveQueryStr . ' ON DUPLICATE KEY UPDATE `divergence`=VALUES(`divergence`);'); - } - - shell_exec('rm -f ' . DIVERGENCE_TMP_PATH . '*'); - } - - if ($rServers[SERVER_ID]['is_main']) { - if ($rRedis) { - $rDeleteQuery = "DELETE FROM `lines_divergence` WHERE `uuid` NOT IN ('" . implode("','", $rLiveKeys) . "');"; - for ($rRetry = 0; $rRetry < 3; $rRetry++) { - if ($db->query($rDeleteQuery)) { - break; - } - usleep(200000); - } - } else { - $db->query('DELETE FROM `lines_divergence` WHERE `uuid` NOT IN (SELECT `uuid` FROM `lines_live`);'); - } - } - - if ($rServers[SERVER_ID]['is_main']) { - $db->query('DELETE FROM `lines_live` WHERE `uuid` IS NULL;'); - } - } + use CronTrait; + + private $rPHPPIDs = []; + + private $rServers = []; + + public function getName(): string { + return 'cron:users'; + } + + public function getDescription(): string { + return 'Cron: manage user connections, Redis sync, divergence'; + } + + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } + + set_time_limit(0); + ini_set('memory_limit', -1); + + $this->setProcessTitle('XC_VM[Users]'); + $this->acquireCronLock(); + + global $db; + + $rSync = null; + $this->rServers = ServerRepository::getAll(); + + if (!empty($rArgs[0]) && $this->rServers[SERVER_ID]['is_main']) { + RedisManager::ensureConnected(); + + if (RedisManager::isConnected()) { + $rSync = intval($rArgs[0]); + + if ($rSync == 1) { + $rDeSync = $rRedisUsers = $rRedisUpdate = $rRedisSet = []; + $db->query('SELECT * FROM `lines_live` WHERE `hls_end` = 0;'); + $rRows = $db->get_rows(); + + if (count($rRows) > 0) { + $rStreamIDs = []; + + foreach ($rRows as $rRow) { + $streamId = (int) $rRow['stream_id']; + if ($streamId > 0 && !in_array($streamId, $rStreamIDs)) { + $rStreamIDs[] = $streamId; + } + } + + $rOnDemand = []; + if (count($rStreamIDs) > 0) { + $db->query('SELECT `stream_id`, `server_id`, `on_demand` FROM `streams_servers` WHERE `stream_id` IN (' . implode(',', $rStreamIDs) . ');'); + foreach ($db->get_rows() as $rRow) { + $rOnDemand[$rRow['stream_id']][$rRow['server_id']] = intval($rRow['on_demand']); + } + } + + $rRedis = RedisManager::instance()->multi(); + + foreach ($rRows as $rRow) { + echo 'Resynchronising UUID: ' . $rRow['uuid'] . "\n"; + + if (empty($rRow['hmac_id'])) { + $rRow['identity'] = $rRow['user_id']; + } else { + $rRow['identity'] = $rRow['hmac_id'] . '_' . $rRow['hmac_identifier']; + } + + $rRow['on_demand'] = ($rOnDemand[$rRow['stream_id']][$rRow['server_id']] ?: 0); + $rRedis->zAdd('LINE#' . $rRow['identity'], $rRow['date_start'], $rRow['uuid']); + $rRedis->zAdd('LINE_ALL#' . $rRow['identity'], $rRow['date_start'], $rRow['uuid']); + $rRedis->zAdd('STREAM#' . $rRow['stream_id'], $rRow['date_start'], $rRow['uuid']); + $rRedis->zAdd('SERVER#' . $rRow['server_id'], $rRow['date_start'], $rRow['uuid']); + + if ($rRow['user_id']) { + $rRedis->zAdd('SERVER_LINES#' . $rRow['server_id'], $rRow['user_id'], $rRow['uuid']); + } + + if ($rRow['proxy_id']) { + $rRedis->zAdd('PROXY#' . $rRow['proxy_id'], $rRow['date_start'], $rRow['uuid']); + } + + $rRedis->zAdd('CONNECTIONS', $rRow['date_start'], $rRow['uuid']); + $rRedis->zAdd('LIVE', $rRow['date_start'], $rRow['uuid']); + $rRedis->set($rRow['uuid'], igbinary_serialize($rRow)); + $rDeSync[] = $rRow['uuid']; + } + $rRedis->exec(); + + if (count($rDeSync) > 0) { + $db->query("DELETE FROM `lines_live` WHERE `uuid` IN ('" . implode("','", $rDeSync) . "');"); + } + } + } + } else { + echo "Couldn't connect to Redis.\n"; + return 1; + } + } + + if (SettingsManager::getBool('redis_handler') && $this->rServers[SERVER_ID]['is_main']) { + $this->rServers = ServerRepository::getAll(true); + + foreach ($this->rServers as $rServer) { + $rDecodedPids = json_decode($rServer['php_pids'] ?? '', true); + $this->rPHPPIDs[$rServer['id']] = is_array($rDecodedPids) ? array_map('intval', $rDecodedPids) : []; + } + } + + $this->loadCron(); + + return 0; + } + + private function processDeletions($rDelete, $rDelStream = []) { + $rRedis = SettingsManager::getBool('redis_handler'); + global $db; + $rTime = time(); + + if ($rRedis) { + // Redis can die mid-run — postpone cleanup instead of crashing the + // cron; the entries stay in LIVE/ENDED and are re-detected next run. + if ($rDelete['count'] > 0 && ($rRedisInstance = RedisManager::instance())) { + $rRedis = $rRedisInstance->multi(); + + foreach ($rDelete['line'] as $rUserID => $rUUIDs) { + $rRedis->zRem('LINE#' . $rUserID, ...$rUUIDs); + $rRedis->zRem('LINE_ALL#' . $rUserID, ...$rUUIDs); + } + + foreach ($rDelete['stream'] as $rStreamID => $rUUIDs) { + $rRedis->zRem('STREAM#' . $rStreamID, ...$rUUIDs); + } + + foreach ($rDelete['server'] as $rServerID => $rUUIDs) { + $rRedis->zRem('SERVER#' . $rServerID, ...$rUUIDs); + $rRedis->zRem('SERVER_LINES#' . $rServerID, ...$rUUIDs); + } + + foreach ($rDelete['proxy'] as $rProxyID => $rUUIDs) { + $rRedis->zRem('PROXY#' . $rProxyID, ...$rUUIDs); + } + + if (count($rDelete['uuid']) > 0) { + $rRedis->zRem('CONNECTIONS', ...$rDelete['uuid']); + $rRedis->zRem('LIVE', ...$rDelete['uuid']); + $rRedis->sRem('ENDED', ...$rDelete['uuid']); + $rRedis->del(...$rDelete['uuid']); + } + + $rRedis->exec(); + } elseif ($rDelete['count'] > 0) { + echo "Redis unavailable, connection cleanup postponed until next run\n"; + } + } else { + foreach ($rDelete as $rServerID => $rConnections) { + if (count($rConnections) > 0) { + $db->query("DELETE FROM `lines_live` WHERE `uuid` IN ('" . implode("','", $rConnections) . "')"); + } + } + } + + foreach (($rRedis ? $rDelete['server'] : $rDelete) as $rServerID => $rConnections) { + if ($rServerID != SERVER_ID) { + $rQuery = ''; + + foreach ($rConnections as $rConnection) { + $rQuery .= '(' . $rServerID . ',1,' . $rTime . ',' . $db->escape(json_encode(['type' => 'delete_con', 'uuid' => $rConnection])) . '),'; + } + $rQuery = rtrim($rQuery, ','); + + if (!empty($rQuery)) { + $db->query('INSERT INTO `signals`(`server_id`, `cache`, `time`, `custom_data`) VALUES ' . $rQuery . ';'); + } + } + } + + foreach ($rDelStream as $rStreamID => $rConnections) { + foreach ($rConnections as $rConnection) { + @unlink(CONS_TMP_PATH . $rStreamID . '/' . $rConnection); + } + } + + if ($rRedis) { + return ['line' => [], 'server' => [], 'server_lines' => [], 'proxy' => [], 'stream' => [], 'uuid' => [], 'count' => 0]; + } + + return []; + } + + /** + * Blob 'identity' may be absent (written by an external component or a + * pre-identity build) — derive it the same way the sync path does. + * + * @param array $rConnection Deserialized connection blob. + * @return int|string + */ + private function connectionIdentity(array $rConnection) { + if (isset($rConnection['identity'])) { + return $rConnection['identity']; + } + + if (empty($rConnection['hmac_id'])) { + return intval($rConnection['user_id'] ?? 0); + } + + return $rConnection['hmac_id'] . '_' . ($rConnection['hmac_identifier'] ?? ''); + } + + private function loadCron(): void { + $rRedis = SettingsManager::getBool('redis_handler'); + global $db; + + $rServers = $this->rServers; + $rPHPPIDs = $this->rPHPPIDs; + + if ($rRedis) { + RedisManager::ensureConnected(); + } + + $rStartTime = time(); + $rLiveKeys = []; + + if (!$rRedis || $rServers[SERVER_ID]['is_main']) { + $rAutoKick = SettingsManager::getInt('user_auto_kick_hours') * 3600; + $rLiveKeys = $rDelete = $rDeleteStream = []; + $rRedisDelete = ['line' => [], 'server' => [], 'server_lines' => [], 'proxy' => [], 'stream' => [], 'uuid' => [], 'count' => 0]; + + if ($rRedis) { + $rUsers = []; + $rResult = ConnectionTracker::getConnections(); + $rKeys = $rResult[0] ?? []; + $rConnections = $rResult[1] ?? []; + $i = 0; + + for ($rSize = count($rConnections); $i < $rSize; $i++) { + $rConnection = $rConnections[$i]; + + if (is_array($rConnection)) { + $rConnection['identity'] = $this->connectionIdentity($rConnection); + $rUsers[$rConnection['identity']][] = $rConnection; + $rLiveKeys[] = $rConnection['uuid']; + } else { + $rRedisDelete['count']++; + $rRedisDelete['uuid'][] = $rKeys[$i]; + } + } + unset($rConnections); + } else { + $rUsers = ConnectionTracker::getConnections(($rServers[SERVER_ID]['is_main'] ? null : SERVER_ID)); + } + + $rRestreamerArray = $rMaxConnectionsArray = []; + $rUserIDs = InputValidator::confirmIDs(array_keys($rUsers)); + + if (count($rUserIDs) > 0) { + $db->query('SELECT `id`, `max_connections`, `is_restreamer` FROM `lines` WHERE `id` IN (' . implode(',', $rUserIDs) . ');'); + + foreach ($db->get_rows() as $rRow) { + $rMaxConnectionsArray[$rRow['id']] = $rRow['max_connections']; + $rRestreamerArray[$rRow['id']] = $rRow['is_restreamer']; + } + } + + if ($rRedis && $rServers[SERVER_ID]['is_main']) { + foreach (ConnectionTracker::getEnded() as $rConnection) { + if (is_array($rConnection)) { + $rConnection['identity'] = $this->connectionIdentity($rConnection); + if (!in_array($rConnection['container'], ['ts', 'hls', 'rtmp']) && time() - $rConnection['hls_last_read'] < 300) { + $rClose = false; + } else { + $rClose = true; + } + + if ($rClose) { + echo 'Close connection: ' . $rConnection['uuid'] . "\n"; + ConnectionTracker::closeConnection($rConnection, false, false); + $rRedisDelete['count']++; + $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; + $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; + $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; + $rRedisDelete['uuid'][] = $rConnection['uuid']; + + if ($rConnection['proxy_id']) { + $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; + } + } + } + } + + if ($rRedisDelete['count'] >= 1000) { + $rRedisDelete = $this->processDeletions($rRedisDelete, $rRedisDelete['stream']); + } + } + + foreach ($rUsers as $rUserID => $rConnections) { + $rActiveCount = 0; + // HMAC identities and deleted lines have no row in `lines` — + // 0 disables the max-connections kick below. + $rMaxConnections = $rMaxConnectionsArray[$rUserID] ?? 0; + $rIsRestreamer = !empty($rRestreamerArray[$rUserID]); + + foreach ($rConnections as $rConnection) { + if ($rConnection['server_id'] == SERVER_ID || $rRedis) { + if (!isset($rConnection['exp_date']) || is_null($rConnection['exp_date']) || $rConnection['exp_date'] >= $rStartTime) { + $rTotalTime = $rStartTime - $rConnection['date_start']; + + if (!($rAutoKick != 0 && $rAutoKick <= $rTotalTime) || $rIsRestreamer) { + if ($rConnection['container'] == 'hls') { + if (30 <= $rStartTime - $rConnection['hls_last_read'] || $rConnection['hls_end'] == 1) { + echo 'Close connection: ' . $rConnection['uuid'] . "\n"; + ConnectionTracker::closeConnection($rConnection, false, false); + + if ($rRedis) { + $rRedisDelete['count']++; + $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; + $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; + $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; + $rRedisDelete['uuid'][] = $rConnection['uuid']; + + if ($rConnection['user_id']) { + $rRedisDelete['server_lines'][$rConnection['server_id']][] = $rConnection['uuid']; + } + + if ($rConnection['proxy_id']) { + $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; + } + } else { + $rDelete[$rConnection['server_id']][] = $rConnection['uuid']; + $rDeleteStream[$rConnection['stream_id']] = $rDelete[$rConnection['server_id']]; + } + } + } else { + // Daemon-served TS (pid=0, ADR 0003 Phase C) has no PHP + // worker to probe — the fanout_sync daemon closes it by + // reconciling against the daemon's live-connection set. + // Skip it here so this reaper neither leaks it (a live + // reused worker pid reads as "running") nor closes it + // early (when that worker recycles). + if ($rConnection['container'] != 'rtmp' && intval($rConnection['pid']) !== 0) { + if ($rConnection['server_id'] == SERVER_ID) { + $rIsRunning = ProcessManager::isRunning($rConnection['pid'], 'php-fpm'); + } else { + if ($rConnection['date_start'] <= $rServers[$rConnection['server_id']]['last_check_ago'] - 1 && 0 < count($rPHPPIDs[$rConnection['server_id']])) { + $rIsRunning = in_array(intval($rConnection['pid']), $rPHPPIDs[$rConnection['server_id']]); + } else { + $rIsRunning = true; + } + } + + if (($rConnection['hls_end'] == 1 && ($rStartTime - $rConnection['hls_last_read']) >= 300) || !$rIsRunning) { + echo 'Close connection: ' . $rConnection['uuid'] . "\n"; + ConnectionTracker::closeConnection($rConnection, false, false); + + if ($rRedis) { + $rRedisDelete['count']++; + $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; + $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; + $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; + $rRedisDelete['uuid'][] = $rConnection['uuid']; + + if ($rConnection['user_id']) { + $rRedisDelete['server_lines'][$rConnection['server_id']][] = $rConnection['uuid']; + } + + if ($rConnection['proxy_id']) { + $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; + } + } else { + $rDelete[$rConnection['server_id']][] = $rConnection['uuid']; + $rDeleteStream[$rConnection['stream_id']] = $rDelete[$rConnection['server_id']]; + } + } + } + } + } else { + echo 'Close connection: ' . $rConnection['uuid'] . "\n"; + ConnectionTracker::closeConnection($rConnection, false, false); + + if ($rRedis) { + $rRedisDelete['count']++; + $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; + $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; + $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; + $rRedisDelete['uuid'][] = $rConnection['uuid']; + + if ($rConnection['user_id']) { + $rRedisDelete['server_lines'][$rConnection['server_id']][] = $rConnection['uuid']; + } + + if ($rConnection['proxy_id']) { + $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; + } + } else { + $rDelete[$rConnection['server_id']][] = $rConnection['uuid']; + $rDeleteStream[$rConnection['stream_id']] = $rDelete[$rConnection['server_id']]; + } + } + } else { + echo 'Close connection: ' . $rConnection['uuid'] . "\n"; + ConnectionTracker::closeConnection($rConnection, false, false); + + if ($rRedis) { + $rRedisDelete['count']++; + $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; + $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; + $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; + $rRedisDelete['uuid'][] = $rConnection['uuid']; + + if ($rConnection['user_id']) { + $rRedisDelete['server_lines'][$rConnection['server_id']][] = $rConnection['uuid']; + } + + if ($rConnection['proxy_id']) { + $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; + } + } else { + $rDelete[$rConnection['server_id']][] = $rConnection['uuid']; + $rDeleteStream[$rConnection['stream_id']] = $rDelete[$rConnection['server_id']]; + } + } + } + + if (!$rConnection['hls_end']) { + $rActiveCount++; + } + } + + if ($rServers[SERVER_ID]['is_main'] && 0 < $rMaxConnections && $rMaxConnections < $rActiveCount) { + foreach ($rConnections as $rConnection) { + if (!$rConnection['hls_end']) { + echo 'Close connection: ' . $rConnection['uuid'] . "\n"; + ConnectionTracker::closeConnection($rConnection, false, false); + + if ($rRedis) { + $rRedisDelete['count']++; + $rRedisDelete['line'][$rConnection['identity']][] = $rConnection['uuid']; + $rRedisDelete['stream'][$rConnection['stream_id']][] = $rConnection['uuid']; + $rRedisDelete['server'][$rConnection['server_id']][] = $rConnection['uuid']; + $rRedisDelete['uuid'][] = $rConnection['uuid']; + + if ($rConnection['user_id']) { + $rRedisDelete['server_lines'][$rConnection['server_id']][] = $rConnection['uuid']; + } + + if ($rConnection['proxy_id']) { + $rRedisDelete['proxy'][$rConnection['proxy_id']][] = $rConnection['uuid']; + } + } else { + $rDelete[$rConnection['server_id']][] = $rConnection['uuid']; + $rDeleteStream[$rConnection['stream_id']] = $rDelete[$rConnection['server_id']]; + } + + $rActiveCount--; + } + + if ($rActiveCount >= $rMaxConnections) { + break; + } + } + } + + if ($rRedis && 1000 <= $rRedisDelete['count']) { + $rRedisDelete = $this->processDeletions($rRedisDelete, $rRedisDelete['stream']); + } else { + if (!$rRedis && count($rDelete) >= 1000) { + $rDelete = $this->processDeletions($rDelete, $rDeleteStream); + } + } + } + + if ($rRedis && 0 < $rRedisDelete['count']) { + $this->processDeletions($rRedisDelete, $rRedisDelete['stream']); + } else { + if (!$rRedis && count($rDelete) > 0) { + $this->processDeletions($rDelete, $rDeleteStream); + } + } + } + + $rConnectionSpeeds = glob(DIVERGENCE_TMP_PATH . '*'); + + if (count($rConnectionSpeeds) > 0) { + $rBitrates = []; + + if ($rRedis) { + $rStreamMap = []; + + $db->query('SELECT `stream_id`, `bitrate` FROM `streams_servers` WHERE `server_id` = ? AND `bitrate` IS NOT NULL;', SERVER_ID); + foreach ($db->get_rows() as $rRow) { + $bitrate = intval($rRow['bitrate']); + if ($bitrate > 0) { + $rStreamMap[intval($rRow['stream_id'])] = intval($bitrate / 8 * 0.92); + } + } + + $rUUIDs = []; + foreach ($rConnectionSpeeds as $rConnectionSpeed) { + if (!empty($rConnectionSpeed)) { + $rUUIDs[] = basename($rConnectionSpeed); + } + } + + if (count($rUUIDs) > 0) { + $rRedis = RedisManager::instance(); + if (!$rRedis) { + $rConnections = []; + } else { + $rConnections = array_map( + static fn($v) => ($v !== false) ? igbinary_unserialize($v) : null, + $rRedis->mGet($rUUIDs) + ); + } + + foreach ($rConnections as $rConnection) { + if (!is_array($rConnection)) { + continue; + } + + $uuid = $rConnection['uuid']; + $streamId = intval($rConnection['stream_id']); + + if (!isset($rStreamMap[$streamId])) { + continue; + } + + $rBitrates[$uuid] = $rStreamMap[$streamId]; + } + } + + unset($rStreamMap); + } else { + $db->query('SELECT `lines_live`.`uuid`, `streams_servers`.`bitrate` FROM `lines_live` LEFT JOIN `streams_servers` ON `lines_live`.`stream_id` = `streams_servers`.`stream_id` AND `lines_live`.`server_id` = `streams_servers`.`server_id` WHERE `lines_live`.`server_id` = ?;', SERVER_ID); + + foreach ($db->get_rows() as $rRow) { + $bitrate = intval($rRow['bitrate']); + if ($bitrate > 0) { + $rBitrates[$rRow['uuid']] = intval($bitrate / 8 * 0.92); + } + } + } + + if (!$rRedis) { + $rUUIDMap = []; + $db->query('SELECT `uuid`, `activity_id` FROM `lines_live`;'); + foreach ($db->get_rows() as $rRow) { + $rUUIDMap[$rRow['uuid']] = $rRow['activity_id']; + } + } + + $rLiveQuery = $rDivergenceUpdate = []; + + foreach ($rConnectionSpeeds as $rConnectionSpeed) { + if (empty($rConnectionSpeed)) { + continue; + } + + $rUUID = basename($rConnectionSpeed); + $rAverageSpeed = intval(file_get_contents($rConnectionSpeed)); + + if (!isset($rBitrates[$rUUID]) || $rBitrates[$rUUID] <= 0) { + $rDivergenceUpdate[] = "('" . $rUUID . "', 0)"; + + if (!$rRedis && isset($rUUIDMap[$rUUID])) { + $rLiveQuery[] = '(' . $rUUIDMap[$rUUID] . ', 0)'; + } + + continue; + } + + $realBitrate = $rBitrates[$rUUID]; + $rDivergence = intval(($rAverageSpeed - $realBitrate) / $realBitrate * 100); + + if ($rDivergence > 0) { + $rDivergence = 0; + } + + $rDivergenceUpdate[] = "('" . $rUUID . "', " . abs($rDivergence) . ')'; + + if (!$rRedis && isset($rUUIDMap[$rUUID])) { + $rLiveQuery[] = '(' . $rUUIDMap[$rUUID] . ', ' . abs($rDivergence) . ')'; + } + } + + if (count($rDivergenceUpdate) > 0) { + $rUpdateQuery = implode(',', $rDivergenceUpdate); + $db->query('INSERT INTO `lines_divergence`(`uuid`,`divergence`) VALUES ' . $rUpdateQuery . ' ON DUPLICATE KEY UPDATE `divergence`=VALUES(`divergence`);'); + } + + if (!$rRedis && count($rLiveQuery) > 0) { + $rLiveQueryStr = implode(',', $rLiveQuery); + $db->query('INSERT INTO `lines_live`(`activity_id`,`divergence`) VALUES ' . $rLiveQueryStr . ' ON DUPLICATE KEY UPDATE `divergence`=VALUES(`divergence`);'); + } + + shell_exec('rm -f ' . DIVERGENCE_TMP_PATH . '*'); + } + + if ($rServers[SERVER_ID]['is_main']) { + if ($rRedis) { + $rDeleteQuery = "DELETE FROM `lines_divergence` WHERE `uuid` NOT IN ('" . implode("','", $rLiveKeys) . "');"; + for ($rRetry = 0; $rRetry < 3; $rRetry++) { + if ($db->query($rDeleteQuery)) { + break; + } + usleep(200000); + } + } else { + $db->query('DELETE FROM `lines_divergence` WHERE `uuid` NOT IN (SELECT `uuid` FROM `lines_live`);'); + } + } + + if ($rServers[SERVER_ID]['is_main']) { + $db->query('DELETE FROM `lines_live` WHERE `uuid` IS NULL;'); + } + } } diff --git a/src/Cli/CronJobs/VodCronJob.php b/src/Cli/CronJobs/VodCronJob.php index e295da81..28689783 100644 --- a/src/Cli/CronJobs/VodCronJob.php +++ b/src/Cli/CronJobs/VodCronJob.php @@ -23,178 +23,178 @@ use XcVm\Streaming\Health\ProcessChecker; */ class VodCronJob implements CommandInterface { - use CronTrait; + use CronTrait; - public function getName(): string { - return 'cron:vod'; - } + public function getName(): string { + return 'cron:vod'; + } - public function getDescription(): string { - return 'Cron: check VOD/channels, start recordings, analyze media'; - } + public function getDescription(): string { + return 'Cron: check VOD/channels, start recordings, analyze media'; + } - public function execute(array $rArgs): int { - if (!$this->assertRunAsXcVm()) { - return 1; - } + public function execute(array $rArgs): int { + if (!$this->assertRunAsXcVm()) { + return 1; + } - $this->initCron('XC_VM[VOD]'); - $this->loadCron(); + $this->initCron('XC_VM[VOD]'); + $this->loadCron(); - return 0; - } + return 0; + } - private function loadCron(): void { - global $db; + private function loadCron(): void { + global $db; - $db->query('SELECT * FROM `streams` t1 INNER JOIN `streams_servers` t3 ON t3.stream_id = t1.id LEFT JOIN `profiles` t2 ON t2.profile_id = t1.transcode_profile_id WHERE t1.type = 3 AND t3.server_id = ? AND t3.parent_id IS NULL;', SERVER_ID); - if ($db->num_rows() > 0) { - $rStreams = $db->get_rows(); - foreach ($rStreams as $rStream) { - echo "\n\n" . '[*] Checking Stream ' . $rStream['stream_display_name'] . "\n"; - $rCreateFile = CREATED_PATH . $rStream['id'] . '_.create'; - $rPID = is_file($rCreateFile) ? intval(file_get_contents($rCreateFile)) : 0; - if ($rPID && ProcessChecker::checkPID($rPID, 'XC_VMCreate[' . intval($rStream['id']) . ']')) { - echo "\t" . 'Build Is Still Going!' . "\n"; - } else { - $rSourcesLeft = array_diff(json_decode($rStream['stream_source'], true), json_decode($rStream['cchannel_rsources'], true)); - if (count($rSourcesLeft) > 0) { - echo "\t" . 'Needs Updating!' . "\n"; - StreamProcess::queueChannel($rStream['id']); - } else { - if (file_exists(CREATED_PATH . $rStream['id'] . '_.info')) { - $rCCInfo = file_get_contents(CREATED_PATH . $rStream['id'] . '_.info'); - $db->query('UPDATE `streams_servers` SET `cc_info` = ? WHERE `server_id` = ? AND `stream_id` = ?;', $rCCInfo, SERVER_ID, $rStream['id']); - unlink(CREATED_PATH . $rStream['id'] . '_.info'); - } - echo "\t" . 'Build Finished' . "\n"; - } - } - } - } + $db->query('SELECT * FROM `streams` t1 INNER JOIN `streams_servers` t3 ON t3.stream_id = t1.id LEFT JOIN `profiles` t2 ON t2.profile_id = t1.transcode_profile_id WHERE t1.type = 3 AND t3.server_id = ? AND t3.parent_id IS NULL;', SERVER_ID); + if ($db->num_rows() > 0) { + $rStreams = $db->get_rows(); + foreach ($rStreams as $rStream) { + echo "\n\n" . '[*] Checking Stream ' . $rStream['stream_display_name'] . "\n"; + $rCreateFile = CREATED_PATH . $rStream['id'] . '_.create'; + $rPID = is_file($rCreateFile) ? intval(file_get_contents($rCreateFile)) : 0; + if ($rPID && ProcessChecker::checkPID($rPID, 'XC_VMCreate[' . intval($rStream['id']) . ']')) { + echo "\t" . 'Build Is Still Going!' . "\n"; + } else { + $rSourcesLeft = array_diff(json_decode($rStream['stream_source'], true), json_decode($rStream['cchannel_rsources'], true)); + if (count($rSourcesLeft) > 0) { + echo "\t" . 'Needs Updating!' . "\n"; + StreamProcess::queueChannel($rStream['id']); + } else { + if (file_exists(CREATED_PATH . $rStream['id'] . '_.info')) { + $rCCInfo = file_get_contents(CREATED_PATH . $rStream['id'] . '_.info'); + $db->query('UPDATE `streams_servers` SET `cc_info` = ? WHERE `server_id` = ? AND `stream_id` = ?;', $rCCInfo, SERVER_ID, $rStream['id']); + unlink(CREATED_PATH . $rStream['id'] . '_.info'); + } + echo "\t" . 'Build Finished' . "\n"; + } + } + } + } - $db->query('SELECT `id` FROM `recordings` WHERE `status` NOT IN (1,2) AND `source_id` = ? AND ((`start` <= UNIX_TIMESTAMP() AND `end` > UNIX_TIMESTAMP()) OR (`archive` = 1));', SERVER_ID); - if ($db->num_rows() > 0) { - foreach ($db->get_rows() as $rRow) { - echo 'Start recording ID: ' . intval($rRow['id']) . "\n"; - shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php record ' . intval($rRow['id']) . ' > /dev/null 2>/dev/null &'); - } - } + $db->query('SELECT `id` FROM `recordings` WHERE `status` NOT IN (1,2) AND `source_id` = ? AND ((`start` <= UNIX_TIMESTAMP() AND `end` > UNIX_TIMESTAMP()) OR (`archive` = 1));', SERVER_ID); + if ($db->num_rows() > 0) { + foreach ($db->get_rows() as $rRow) { + echo 'Start recording ID: ' . intval($rRow['id']) . "\n"; + shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php record ' . intval($rRow['id']) . ' > /dev/null 2>/dev/null &'); + } + } - exec("ps ax | grep 'ffmpeg' | awk '{print \$1}'", $rPIDs); + exec("ps ax | grep 'ffmpeg' | awk '{print \$1}'", $rPIDs); - $db->query('SELECT COUNT(*) AS `count` FROM `streams_servers` WHERE `to_analyze` = 1 AND `server_id` = ?', SERVER_ID); - $rCount = $db->get_row()['count']; + $db->query('SELECT COUNT(*) AS `count` FROM `streams_servers` WHERE `to_analyze` = 1 AND `server_id` = ?', SERVER_ID); + $rCount = $db->get_row()['count']; - if ($rCount > 0) { - if ($rCount <= 1000) { - $rSteps = [0, $rCount]; - } else { - $rSteps = range(0, $rCount, 1000); - } - if (!$rSteps) { - $rSteps = array(0); - } + if ($rCount > 0) { + if ($rCount <= 1000) { + $rSteps = [0, $rCount]; + } else { + $rSteps = range(0, $rCount, 1000); + } + if (!$rSteps) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - $db->query('SELECT t1.*,t2.* FROM `streams_servers` t1 INNER JOIN `streams` t2 ON t2.id = t1.stream_id AND t2.direct_source = 0 INNER JOIN `streams_types` t3 ON t3.type_id = t2.type AND t3.live = 0 WHERE t1.to_analyze = 1 AND t1.server_id = ? LIMIT ' . $rStep . ', 1000', SERVER_ID); - if ($db->num_rows() <= 0) { - continue; - } + foreach ($rSteps as $rStep) { + $db->query('SELECT t1.*,t2.* FROM `streams_servers` t1 INNER JOIN `streams` t2 ON t2.id = t1.stream_id AND t2.direct_source = 0 INNER JOIN `streams_types` t3 ON t3.type_id = t2.type AND t3.live = 0 WHERE t1.to_analyze = 1 AND t1.server_id = ? LIMIT ' . $rStep . ', 1000', SERVER_ID); + if ($db->num_rows() <= 0) { + continue; + } - $rRows = $db->get_rows(); - foreach ($rRows as $rRow) { - echo '[*] Checking Movie ' . $rRow['stream_display_name'] . ' ' . "\t\t" . '---> '; - if (in_array($rRow['pid'], $rPIDs)) { - echo 'ENCODING...' . "\n"; - } else { - $rMoviePath = VOD_PATH . intval($rRow['stream_id']) . '.' . escapeshellcmd($rRow['target_container']); - if ($rFFProbee = FFprobeRunner::probeStream($rMoviePath)) { - if (!isset($rFFProbee['codecs']['video']) || !is_array($rFFProbee['codecs']['video'])) { - // ffprobe opened the file but found no usable video stream - // (e.g. a truncated/placeholder upload): parseFFProbe returns - // '' for the missing codec, and the VALID branch below treats - // it as an array ($rFFProbee['codecs']['video']['codec_name']), - // which is a TypeError on PHP 8 that aborts the whole analyzer - // run and leaves every remaining movie stuck in `to_analyze = 1` - // (yellow) forever. Treat such a file as broken instead. - $db->query('UPDATE `streams_servers` SET `to_analyze` = 0,`stream_status` = 1 WHERE `server_stream_id` = ?', $rRow['server_stream_id']); - echo 'BROKEN (no video stream)' . "\n"; - StreamProcess::updateStream($rRow['stream_id']); - continue; - } - // ffprobe (especially over network/rclone mounts) can still - // return a partial result for an odd file — e.g. a video stream - // but no audio, where parseFFProbe stores '' (a string) instead - // of an array. The VALID branch dereferences these as arrays - // ($rFFProbee['codecs']['audio']['codec_name']), a TypeError on - // PHP 8 that aborts the whole run. Normalise to arrays. - foreach (array('video', 'audio') as $rCodecKind) { - if (!is_array($rFFProbee['codecs'][$rCodecKind] ?? null)) { - $rFFProbee['codecs'][$rCodecKind] = array(); - } - } - $rDuration = (isset($rFFProbee['duration']) ? $rFFProbee['duration'] : 0); - sscanf($rDuration, '%d:%d:%d', $rHours, $rMinutes, $rSeconds); - $rSeconds = (isset($rSeconds) ? $rHours * 3600 + $rMinutes * 60 + $rSeconds : $rHours * 60 + $rMinutes); - $rSize = filesize($rMoviePath); - // Guard against a zero/unknown duration (ffprobe reports - // 'N/A' for truncated or duration-less files): dividing by - // it throws DivisionByZeroError on PHP 8, which aborts the - // whole analyzer run and leaves every remaining movie stuck - // in `to_analyze = 1` (yellow) forever. - $rBitrate = ($rSeconds > 0 ? round(($rSize * 0.008) / $rSeconds) : 0); - $rMovieProperties = json_decode($rRow['movie_properties'], true); - if (!is_array($rMovieProperties)) { - $rMovieProperties = array(); - } - if (!(isset($rMovieProperties['duration_secs']) && $rSeconds == $rMovieProperties['duration_secs'])) { - $rMovieProperties['duration_secs'] = $rSeconds; - $rMovieProperties['duration'] = $rDuration; - } - if (!(isset($rMovieProperties['video']) && $rFFProbee['codecs']['video']['codec_name'] == $rMovieProperties['video'])) { - $rMovieProperties['video'] = $rFFProbee['codecs']['video']; - } - if (!(isset($rMovieProperties['audio']) && $rFFProbee['codecs']['audio']['codec_name'] == $rMovieProperties['audio'])) { - $rMovieProperties['audio'] = $rFFProbee['codecs']['audio']; - } - if (SettingsManager::get('extract_subtitles')) { - if (!(isset($rMovieProperties['subtitle']) && $rFFProbee['codecs']['subtitle']['codec_name'] == $rMovieProperties['subtitle'])) { - $rMovieProperties['subtitle'] = $rFFProbee['codecs']['subtitle']; - } - } - if (!(isset($rMovieProperties['bitrate']) && $rBitrate == $rMovieProperties['bitrate'])) { - if (0 < $rBitrate) { - $rMovieProperties['bitrate'] = $rBitrate; - } else { - $rBitrate = $rMovieProperties['bitrate']; - } - } - if (isset($rFFProbee['codecs']['subtitle']) && SettingsManager::get('extract_subtitles')) { - $i = 0; - foreach ($rFFProbee['codecs']['subtitle'] as $rSubtitle) { - FFmpegCommand::extractSubtitle($rRow['stream_id'], $rMoviePath, $i); - $i++; - } - } - $rCompatible = intval(DiagnosticsService::checkCompatibility($rFFProbee, SettingsManager::get('player_allow_hevc'))); - $rAudioCodec = ($rFFProbee['codecs']['audio']['codec_name'] ?: null); - $rVideoCodec = ($rFFProbee['codecs']['video']['codec_name'] ?: null); - $rResolution = ($rFFProbee['codecs']['video']['height'] ?: null); - if ($rResolution) { - $rResolution = StreamSorter::getNearest(array(240, 360, 480, 576, 720, 1080, 1440, 2160), $rResolution); - } - $db->query('UPDATE `streams` SET `movie_properties` = ? WHERE `id` = ?', json_encode($rMovieProperties, JSON_UNESCAPED_UNICODE), $rRow['stream_id']); - $db->query('UPDATE `streams_servers` SET `bitrate` = ?,`to_analyze` = 0,`stream_status` = 0,`stream_info` = ?,`audio_codec` = ?,`video_codec` = ?,`resolution` = ?,`compatible` = ? WHERE `server_stream_id` = ?', $rBitrate, json_encode($rFFProbee, JSON_UNESCAPED_UNICODE), $rAudioCodec, $rVideoCodec, $rResolution, $rCompatible, $rRow['server_stream_id']); - echo 'VALID' . "\n"; - } else { - $db->query('UPDATE `streams_servers` SET `to_analyze` = 0,`stream_status` = 1 WHERE `server_stream_id` = ?', $rRow['server_stream_id']); - echo 'BROKEN' . "\n"; - } - StreamProcess::updateStream($rRow['stream_id']); - } - } - } - } - } + $rRows = $db->get_rows(); + foreach ($rRows as $rRow) { + echo '[*] Checking Movie ' . $rRow['stream_display_name'] . ' ' . "\t\t" . '---> '; + if (in_array($rRow['pid'], $rPIDs)) { + echo 'ENCODING...' . "\n"; + } else { + $rMoviePath = VOD_PATH . intval($rRow['stream_id']) . '.' . escapeshellcmd($rRow['target_container']); + if ($rFFProbee = FFprobeRunner::probeStream($rMoviePath)) { + if (!isset($rFFProbee['codecs']['video']) || !is_array($rFFProbee['codecs']['video'])) { + // ffprobe opened the file but found no usable video stream + // (e.g. a truncated/placeholder upload): parseFFProbe returns + // '' for the missing codec, and the VALID branch below treats + // it as an array ($rFFProbee['codecs']['video']['codec_name']), + // which is a TypeError on PHP 8 that aborts the whole analyzer + // run and leaves every remaining movie stuck in `to_analyze = 1` + // (yellow) forever. Treat such a file as broken instead. + $db->query('UPDATE `streams_servers` SET `to_analyze` = 0,`stream_status` = 1 WHERE `server_stream_id` = ?', $rRow['server_stream_id']); + echo 'BROKEN (no video stream)' . "\n"; + StreamProcess::updateStream($rRow['stream_id']); + continue; + } + // ffprobe (especially over network/rclone mounts) can still + // return a partial result for an odd file — e.g. a video stream + // but no audio, where parseFFProbe stores '' (a string) instead + // of an array. The VALID branch dereferences these as arrays + // ($rFFProbee['codecs']['audio']['codec_name']), a TypeError on + // PHP 8 that aborts the whole run. Normalise to arrays. + foreach (['video', 'audio'] as $rCodecKind) { + if (!is_array($rFFProbee['codecs'][$rCodecKind] ?? null)) { + $rFFProbee['codecs'][$rCodecKind] = []; + } + } + $rDuration = (isset($rFFProbee['duration']) ? $rFFProbee['duration'] : 0); + sscanf($rDuration, '%d:%d:%d', $rHours, $rMinutes, $rSeconds); + $rSeconds = (isset($rSeconds) ? $rHours * 3600 + $rMinutes * 60 + $rSeconds : $rHours * 60 + $rMinutes); + $rSize = filesize($rMoviePath); + // Guard against a zero/unknown duration (ffprobe reports + // 'N/A' for truncated or duration-less files): dividing by + // it throws DivisionByZeroError on PHP 8, which aborts the + // whole analyzer run and leaves every remaining movie stuck + // in `to_analyze = 1` (yellow) forever. + $rBitrate = ($rSeconds > 0 ? round(($rSize * 0.008) / $rSeconds) : 0); + $rMovieProperties = json_decode($rRow['movie_properties'], true); + if (!is_array($rMovieProperties)) { + $rMovieProperties = []; + } + if (!(isset($rMovieProperties['duration_secs']) && $rSeconds == $rMovieProperties['duration_secs'])) { + $rMovieProperties['duration_secs'] = $rSeconds; + $rMovieProperties['duration'] = $rDuration; + } + if (!(isset($rMovieProperties['video']) && $rFFProbee['codecs']['video']['codec_name'] == $rMovieProperties['video'])) { + $rMovieProperties['video'] = $rFFProbee['codecs']['video']; + } + if (!(isset($rMovieProperties['audio']) && $rFFProbee['codecs']['audio']['codec_name'] == $rMovieProperties['audio'])) { + $rMovieProperties['audio'] = $rFFProbee['codecs']['audio']; + } + if (SettingsManager::get('extract_subtitles')) { + if (!(isset($rMovieProperties['subtitle']) && $rFFProbee['codecs']['subtitle']['codec_name'] == $rMovieProperties['subtitle'])) { + $rMovieProperties['subtitle'] = $rFFProbee['codecs']['subtitle']; + } + } + if (!(isset($rMovieProperties['bitrate']) && $rBitrate == $rMovieProperties['bitrate'])) { + if (0 < $rBitrate) { + $rMovieProperties['bitrate'] = $rBitrate; + } else { + $rBitrate = $rMovieProperties['bitrate']; + } + } + if (isset($rFFProbee['codecs']['subtitle']) && SettingsManager::get('extract_subtitles')) { + $i = 0; + foreach ($rFFProbee['codecs']['subtitle'] as $rSubtitle) { + FFmpegCommand::extractSubtitle($rRow['stream_id'], $rMoviePath, $i); + $i++; + } + } + $rCompatible = intval(DiagnosticsService::checkCompatibility($rFFProbee, SettingsManager::get('player_allow_hevc'))); + $rAudioCodec = ($rFFProbee['codecs']['audio']['codec_name'] ?: null); + $rVideoCodec = ($rFFProbee['codecs']['video']['codec_name'] ?: null); + $rResolution = ($rFFProbee['codecs']['video']['height'] ?: null); + if ($rResolution) { + $rResolution = StreamSorter::getNearest([240, 360, 480, 576, 720, 1080, 1440, 2160], $rResolution); + } + $db->query('UPDATE `streams` SET `movie_properties` = ? WHERE `id` = ?', json_encode($rMovieProperties, JSON_UNESCAPED_UNICODE), $rRow['stream_id']); + $db->query('UPDATE `streams_servers` SET `bitrate` = ?,`to_analyze` = 0,`stream_status` = 0,`stream_info` = ?,`audio_codec` = ?,`video_codec` = ?,`resolution` = ?,`compatible` = ? WHERE `server_stream_id` = ?', $rBitrate, json_encode($rFFProbee, JSON_UNESCAPED_UNICODE), $rAudioCodec, $rVideoCodec, $rResolution, $rCompatible, $rRow['server_stream_id']); + echo 'VALID' . "\n"; + } else { + $db->query('UPDATE `streams_servers` SET `to_analyze` = 0,`stream_status` = 1 WHERE `server_stream_id` = ?', $rRow['server_stream_id']); + echo 'BROKEN' . "\n"; + } + StreamProcess::updateStream($rRow['stream_id']); + } + } + } + } + } } diff --git a/src/Cli/CronTrait.php b/src/Cli/CronTrait.php index 8000990c..5f563b22 100644 --- a/src/Cli/CronTrait.php +++ b/src/Cli/CronTrait.php @@ -20,75 +20,74 @@ use XcVm\Core\Util\Encryption; */ trait CronTrait { + /** @var string|null Путь к lock-файлу */ + protected $rIdentifier; - /** @var string|null Путь к lock-файлу */ - protected $rIdentifier; + /** + * Проверить что процесс запущен от пользователя xc_vm. + */ + protected function assertRunAsXcVm(): bool { + if ((posix_getpwuid(posix_geteuid())['name'] ?? null) !== 'xc_vm') { + echo "Please run as XC_VM!\n"; + return false; + } + return true; + } - /** - * Проверить что процесс запущен от пользователя xc_vm. - */ - protected function assertRunAsXcVm(): bool { - if ((posix_getpwuid(posix_geteuid())['name'] ?? null) !== 'xc_vm') { - echo "Please run as XC_VM!\n"; - return false; - } - return true; - } + /** + * Проверить что процесс запущен от root. + */ + protected function assertRunAsRoot(): bool { + if ((posix_getpwuid(posix_geteuid())['name'] ?? null) !== 'root') { + echo "Please run as root!\n"; + return false; + } + return true; + } - /** - * Проверить что процесс запущен от root. - */ - protected function assertRunAsRoot(): bool { - if ((posix_getpwuid(posix_geteuid())['name'] ?? null) !== 'root') { - echo "Please run as root!\n"; - return false; - } - return true; - } + /** + * Установить заголовок процесса + time limit. + */ + protected function setProcessTitle(string $rTitle): void { + set_time_limit(0); + cli_set_process_title($rTitle); + } - /** - * Установить заголовок процесса + time limit. - */ - protected function setProcessTitle(string $rTitle): void { - set_time_limit(0); - cli_set_process_title($rTitle); - } + /** + * Получить cron lock (уникальный файл в CRONS_TMP_PATH). + * Если lock уже занят — выходит с кодом 0. + */ + protected function acquireCronLock(): void { + $this->rIdentifier = CRONS_TMP_PATH . md5( + Encryption::generateUniqueCode(SettingsManager::get('live_streaming_pass')) . static::class + ); + ProcessManager::acquireCronLock($this->rIdentifier); + } - /** - * Получить cron lock (уникальный файл в CRONS_TMP_PATH). - * Если lock уже занят — выходит с кодом 0. - */ - protected function acquireCronLock(): void { - $this->rIdentifier = CRONS_TMP_PATH . md5( - Encryption::generateUniqueCode(SettingsManager::get('live_streaming_pass')) . static::class - ); - ProcessManager::acquireCronLock($this->rIdentifier); - } + /** + * Зарегистрировать shutdown handler: закрытие БД + удаление lock. + */ + protected function registerShutdown(): void { + $rIdentifier = &$this->rIdentifier; + register_shutdown_function(static function () use (&$rIdentifier) { + global $db; + if (isset($db) && is_object($db)) { + $db->close_mysql(); + } + if (!empty($rIdentifier) && file_exists($rIdentifier)) { + @unlink($rIdentifier); + } + }); + } - /** - * Зарегистрировать shutdown handler: закрытие БД + удаление lock. - */ - protected function registerShutdown(): void { - $rIdentifier = &$this->rIdentifier; - register_shutdown_function(static function () use (&$rIdentifier) { - global $db; - if (isset($db) && is_object($db)) { - $db->close_mysql(); - } - if (!empty($rIdentifier) && file_exists($rIdentifier)) { - @unlink($rIdentifier); - } - }); - } - - /** - * Стандартная инициализация cron-задачи. - * - * @param string $rTitle Заголовок процесса (например 'XC_VM[Activity]') - */ - protected function initCron(string $rTitle): void { - $this->registerShutdown(); - $this->setProcessTitle($rTitle); - $this->acquireCronLock(); - } + /** + * Стандартная инициализация cron-задачи. + * + * @param string $rTitle Заголовок процесса (например 'XC_VM[Activity]') + */ + protected function initCron(string $rTitle): void { + $this->registerShutdown(); + $this->setProcessTitle($rTitle); + $this->acquireCronLock(); + } } diff --git a/src/Cli/DaemonTrait.php b/src/Cli/DaemonTrait.php index a9a4aa3c..b6141348 100644 --- a/src/Cli/DaemonTrait.php +++ b/src/Cli/DaemonTrait.php @@ -21,7 +21,6 @@ use XcVm\Infrastructure\Redis\RedisManager; */ trait DaemonTrait { - /** @var string MD5 файла команды при запуске */ protected $rDaemonMD5; diff --git a/src/Cli/migration_logic.php b/src/Cli/migration_logic.php index 57e90d53..055dfd18 100644 --- a/src/Cli/migration_logic.php +++ b/src/Cli/migration_logic.php @@ -19,1575 +19,1575 @@ use XcVm\Core\Database\QueryHelper; set_time_limit(0); ini_set('memory_limit', -1); global $db; -$rXUITableList = array('access_codes', 'users', 'blocked_ips', 'blocked_uas', 'blocked_isps', 'bouquets', 'enigma2_devices', 'mag_devices', 'epg', 'users_groups', 'users_packages', 'rtmp_ips', 'streams_series', 'streams_episodes', 'servers', 'streams', 'streams_options', 'streams_servers', 'streams_categories', 'tickets', 'tickets_replies', 'profiles', 'lines', 'watch_folders'); -$rTableList = array('reg_users', 'users', 'enigma2_devices', 'mag_devices', 'user_output', 'streaming_servers', 'series', 'series_episodes', 'streams', 'streams_sys', 'streams_options', 'stream_categories', 'bouquets', 'member_groups', 'packages', 'rtmp_ips', 'epg', 'blocked_ips', 'blocked_user_agents', 'isp_addon', 'tickets', 'tickets_replies', 'transcoding_profiles', 'watch_folders', 'categories', 'epg_sources', 'members', 'blocked_isps', 'groups', 'servers', 'stream_servers'); -$rMigrateOptions = (json_decode(file_get_contents(TMP_PATH . '.migration.options'), true) ?: array()); +$rXUITableList = ['access_codes', 'users', 'blocked_ips', 'blocked_uas', 'blocked_isps', 'bouquets', 'enigma2_devices', 'mag_devices', 'epg', 'users_groups', 'users_packages', 'rtmp_ips', 'streams_series', 'streams_episodes', 'servers', 'streams', 'streams_options', 'streams_servers', 'streams_categories', 'tickets', 'tickets_replies', 'profiles', 'lines', 'watch_folders']; +$rTableList = ['reg_users', 'users', 'enigma2_devices', 'mag_devices', 'user_output', 'streaming_servers', 'series', 'series_episodes', 'streams', 'streams_sys', 'streams_options', 'stream_categories', 'bouquets', 'member_groups', 'packages', 'rtmp_ips', 'epg', 'blocked_ips', 'blocked_user_agents', 'isp_addon', 'tickets', 'tickets_replies', 'transcoding_profiles', 'watch_folders', 'categories', 'epg_sources', 'members', 'blocked_isps', 'groups', 'servers', 'stream_servers']; +$rMigrateOptions = (json_decode(file_get_contents(TMP_PATH . '.migration.options'), true) ?: []); file_put_contents(TMP_PATH . '.migration.pid', getmypid()); file_put_contents(TMP_PATH . '.migration.status', 1); $odb = new DatabaseHandler(migrate: true); if (!$odb->connected) { - echo 'Failed to connect to migration database, or database is empty!' . "\n"; - file_put_contents(TMP_PATH . '.migration.status', 3); - exit(); + echo 'Failed to connect to migration database, or database is empty!' . "\n"; + file_put_contents(TMP_PATH . '.migration.status', 3); + exit(); } echo 'Connected to migration database.' . "\n"; $AdminAccesCode = ''; $odb->query("SHOW TABLES LIKE 'access_codes';"); if ($odb->num_rows() > 0) { - if (count($rMigrateOptions) == 0) { - $rMigrateOptions = $rXUITableList; - } - $rItemCount = 0; - foreach ($rXUITableList as $rTable) { - $odb->query('SHOW TABLES LIKE ?;', $rTable); - if ($odb->num_rows() > 0) { - $odb->query('SELECT COUNT(*) AS `count` FROM `' . $rTable . '`;'); - $rItemCount += (intval($odb->get_row()['count']) ?: 0); - } - } - if ($rItemCount == 0) { - echo "\n" . "Couldn't find anything to migrate in the `xc_vm_migrate` database. Please ensure you restore your backup to that database specifically." . "\n\n"; - exit(); - } - echo "\n" . 'Migrating database to XC_VM...' . "\n\n"; + if (count($rMigrateOptions) == 0) { + $rMigrateOptions = $rXUITableList; + } + $rItemCount = 0; + foreach ($rXUITableList as $rTable) { + $odb->query('SHOW TABLES LIKE ?;', $rTable); + if ($odb->num_rows() > 0) { + $odb->query('SELECT COUNT(*) AS `count` FROM `' . $rTable . '`;'); + $rItemCount += (intval($odb->get_row()['count']) ?: 0); + } + } + if ($rItemCount == 0) { + echo "\n" . "Couldn't find anything to migrate in the `xc_vm_migrate` database. Please ensure you restore your backup to that database specifically." . "\n\n"; + exit(); + } + echo "\n" . 'Migrating database to XC_VM...' . "\n\n"; - if (in_array('access_codes', $rMigrateOptions)) { - $odb->query('SELECT * FROM `access_codes`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `access_codes`;'); - echo 'Add ' . number_format(count($rResults), 0) . ' access codes.' . "\n"; - foreach ($rResults as $rResult) { - try { - if ($rResult['type'] == 0) { - $AdminAccesCode = $rResult['code']; - } - $rResult = QueryHelper::verifyPostTable('access_codes', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `access_codes`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - AuthRepository::updateCodes(); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('users', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `users`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `users`;'); - echo 'Adding ' . number_format($rCount, 0) . ' users.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `users` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('users', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `users`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('blocked_ips', $rMigrateOptions)) { - $odb->query('SELECT * FROM `blocked_ips`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `blocked_ips`;'); - echo 'Blocking ' . number_format(count($rResults), 0) . ' IP addresses.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('blocked_ips', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `blocked_ips`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('blocked_uas', $rMigrateOptions)) { - $odb->query('SELECT * FROM `blocked_uas`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `blocked_uas`;'); - echo 'Blocking ' . number_format(count($rResults), 0) . ' user-agents.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('blocked_uas', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `blocked_uas`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('blocked_isps', $rMigrateOptions)) { - $odb->query('SELECT * FROM `blocked_isps`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `blocked_isps`;'); - echo 'Blocking ' . number_format(count($rResults), 0) . " ISP's." . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('blocked_isps', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `blocked_isps`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('bouquets', $rMigrateOptions)) { - $odb->query('SELECT * FROM `bouquets`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `bouquets`;'); - echo 'Creating ' . number_format(count($rResults), 0) . ' bouquets.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('bouquets', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `bouquets`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('enigma2_devices', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `enigma2_devices`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `enigma2_devices`;'); - echo 'Authorising ' . number_format($rCount, 0) . ' enigma devices.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `enigma2_devices` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('enigma2_devices', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `enigma2_devices`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('mag_devices', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `mag_devices`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `mag_devices`;'); - echo 'Authorising ' . number_format($rCount, 0) . ' MAG devices.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `mag_devices` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('mag_devices', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `mag_devices`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('epg', $rMigrateOptions)) { - $odb->query('SELECT * FROM `epg`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `epg`;'); - echo 'Processing ' . number_format(count($rResults), 0) . ' EPG URLs.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('epg', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `epg`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('users_groups', $rMigrateOptions)) { - $odb->query('SELECT * FROM `users_groups`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `users_groups`;'); - echo 'Creating ' . number_format(count($rResults), 0) . ' user groups.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('users_groups', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `users_groups`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('users_packages', $rMigrateOptions)) { - $odb->query('SELECT * FROM `users_packages`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `users_packages`;'); - echo 'Creating ' . number_format(count($rResults), 0) . ' user packages.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('users_packages', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `users_packages`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('rtmp_ips', $rMigrateOptions)) { - $odb->query('SELECT * FROM `rtmp_ips`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `rtmp_ips`;'); - echo 'Authorising ' . number_format(count($rResults), 0) . ' RTMP IPs.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('rtmp_ips', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `rtmp_ips`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('streams_series', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `streams_series`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `streams_series`;'); - echo 'Adding ' . number_format($rCount, 0) . ' TV series.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `streams_series` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('streams_series', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams_series`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('streams_episodes', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `streams_episodes`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `streams_episodes`;'); - echo 'Adding ' . number_format($rCount, 0) . ' episodes.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `streams_episodes` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('streams_episodes', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams_episodes`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('servers', $rMigrateOptions)) { - $odb->query('SELECT * FROM `servers` ORDER BY `id` ASC;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $rMain = false; - $db->query('TRUNCATE `servers`;'); - echo 'Moving ' . number_format(count($rResults), 0) . ' servers.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('servers', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('streams', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `streams`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `streams`;'); - echo 'Adding ' . number_format($rCount, 0) . ' streams.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `streams` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('streams', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('streams_options', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `streams_options`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `streams_options`;'); - echo 'Attributing ' . number_format($rCount, 0) . ' options to streams.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `streams_options` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('streams_options', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams_options`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('streams_servers', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `streams_servers`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `streams_servers`;'); - echo 'Allocating ' . number_format($rCount, 0) . ' streams to servers.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `streams_servers` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult['stream_status'] = 0; - $rResult['stream_started'] = null; - $rResult['monitor_pid'] = null; - if ($rResult['pid'] < 0) { - $rResult['pid'] = null; - } - $rResult = QueryHelper::verifyPostTable('streams_servers', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams_servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('streams_categories', $rMigrateOptions)) { - $odb->query('SELECT * FROM `streams_categories`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `streams_categories`;'); - echo 'Creating ' . number_format(count($rResults), 0) . ' categories.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('streams_categories', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams_categories`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('tickets', $rMigrateOptions)) { - $odb->query('SELECT * FROM `tickets`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `tickets`;'); - echo 'Posting ' . number_format(count($rResults), 0) . ' tickets.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('tickets', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `tickets`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('tickets_replies', $rMigrateOptions)) { - $odb->query('SELECT * FROM `tickets_replies`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `tickets_replies`;'); - echo 'Posting ' . number_format(count($rResults), 0) . ' replies.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('tickets_replies', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `tickets_replies`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('profiles', $rMigrateOptions)) { - $odb->query('SELECT * FROM `profiles`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `profiles`;'); - echo 'Generating ' . number_format(count($rResults), 0) . ' transcoding profiles.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('profiles', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `profiles`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('providers', $rMigrateOptions)) { - $odb->query('SELECT * FROM `providers`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `providers`;'); - echo 'Generating ' . number_format(count($rResults), 0) . ' providers.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('providers', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `providers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('lines', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `lines`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `lines`;'); - echo 'Adding ' . number_format($rCount, 0) . ' lines.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `lines` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult['stream_status'] = 0; - $rResult['stream_started'] = null; - $rResult['monitor_pid'] = null; - if ($rResult['pid'] < 0) { - $rResult['pid'] = null; - } - $rResult = QueryHelper::verifyPostTable('lines', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `lines`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('watch_folders', $rMigrateOptions)) { - $odb->query("SHOW TABLES LIKE 'watch_folders';"); - if ($odb->num_rows() > 0) { - $odb->query('SELECT COUNT(*) AS `count` FROM `watch_folders`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `watch_folders`;'); - echo 'Adding ' . number_format($rCount, 0) . ' folders to watch.' . "\n"; - $odb->query('SELECT * FROM `watch_folders`;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('watch_folders', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `watch_folders`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } - } - } + if (in_array('access_codes', $rMigrateOptions)) { + $odb->query('SELECT * FROM `access_codes`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `access_codes`;'); + echo 'Add ' . number_format(count($rResults), 0) . ' access codes.' . "\n"; + foreach ($rResults as $rResult) { + try { + if ($rResult['type'] == 0) { + $AdminAccesCode = $rResult['code']; + } + $rResult = QueryHelper::verifyPostTable('access_codes', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `access_codes`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + AuthRepository::updateCodes(); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('users', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `users`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `users`;'); + echo 'Adding ' . number_format($rCount, 0) . ' users.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `users` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('users', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `users`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('blocked_ips', $rMigrateOptions)) { + $odb->query('SELECT * FROM `blocked_ips`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `blocked_ips`;'); + echo 'Blocking ' . number_format(count($rResults), 0) . ' IP addresses.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('blocked_ips', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `blocked_ips`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('blocked_uas', $rMigrateOptions)) { + $odb->query('SELECT * FROM `blocked_uas`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `blocked_uas`;'); + echo 'Blocking ' . number_format(count($rResults), 0) . ' user-agents.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('blocked_uas', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `blocked_uas`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('blocked_isps', $rMigrateOptions)) { + $odb->query('SELECT * FROM `blocked_isps`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `blocked_isps`;'); + echo 'Blocking ' . number_format(count($rResults), 0) . " ISP's." . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('blocked_isps', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `blocked_isps`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('bouquets', $rMigrateOptions)) { + $odb->query('SELECT * FROM `bouquets`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `bouquets`;'); + echo 'Creating ' . number_format(count($rResults), 0) . ' bouquets.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('bouquets', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `bouquets`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('enigma2_devices', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `enigma2_devices`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `enigma2_devices`;'); + echo 'Authorising ' . number_format($rCount, 0) . ' enigma devices.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `enigma2_devices` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('enigma2_devices', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `enigma2_devices`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('mag_devices', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `mag_devices`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `mag_devices`;'); + echo 'Authorising ' . number_format($rCount, 0) . ' MAG devices.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `mag_devices` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('mag_devices', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `mag_devices`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('epg', $rMigrateOptions)) { + $odb->query('SELECT * FROM `epg`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `epg`;'); + echo 'Processing ' . number_format(count($rResults), 0) . ' EPG URLs.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('epg', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `epg`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('users_groups', $rMigrateOptions)) { + $odb->query('SELECT * FROM `users_groups`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `users_groups`;'); + echo 'Creating ' . number_format(count($rResults), 0) . ' user groups.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('users_groups', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `users_groups`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('users_packages', $rMigrateOptions)) { + $odb->query('SELECT * FROM `users_packages`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `users_packages`;'); + echo 'Creating ' . number_format(count($rResults), 0) . ' user packages.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('users_packages', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `users_packages`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('rtmp_ips', $rMigrateOptions)) { + $odb->query('SELECT * FROM `rtmp_ips`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `rtmp_ips`;'); + echo 'Authorising ' . number_format(count($rResults), 0) . ' RTMP IPs.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('rtmp_ips', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `rtmp_ips`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('streams_series', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `streams_series`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `streams_series`;'); + echo 'Adding ' . number_format($rCount, 0) . ' TV series.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `streams_series` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('streams_series', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams_series`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('streams_episodes', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `streams_episodes`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `streams_episodes`;'); + echo 'Adding ' . number_format($rCount, 0) . ' episodes.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `streams_episodes` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('streams_episodes', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams_episodes`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('servers', $rMigrateOptions)) { + $odb->query('SELECT * FROM `servers` ORDER BY `id` ASC;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $rMain = false; + $db->query('TRUNCATE `servers`;'); + echo 'Moving ' . number_format(count($rResults), 0) . ' servers.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('servers', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('streams', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `streams`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `streams`;'); + echo 'Adding ' . number_format($rCount, 0) . ' streams.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `streams` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('streams', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('streams_options', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `streams_options`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `streams_options`;'); + echo 'Attributing ' . number_format($rCount, 0) . ' options to streams.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `streams_options` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('streams_options', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams_options`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('streams_servers', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `streams_servers`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `streams_servers`;'); + echo 'Allocating ' . number_format($rCount, 0) . ' streams to servers.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `streams_servers` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult['stream_status'] = 0; + $rResult['stream_started'] = null; + $rResult['monitor_pid'] = null; + if ($rResult['pid'] < 0) { + $rResult['pid'] = null; + } + $rResult = QueryHelper::verifyPostTable('streams_servers', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams_servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('streams_categories', $rMigrateOptions)) { + $odb->query('SELECT * FROM `streams_categories`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `streams_categories`;'); + echo 'Creating ' . number_format(count($rResults), 0) . ' categories.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('streams_categories', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams_categories`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('tickets', $rMigrateOptions)) { + $odb->query('SELECT * FROM `tickets`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `tickets`;'); + echo 'Posting ' . number_format(count($rResults), 0) . ' tickets.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('tickets', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `tickets`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('tickets_replies', $rMigrateOptions)) { + $odb->query('SELECT * FROM `tickets_replies`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `tickets_replies`;'); + echo 'Posting ' . number_format(count($rResults), 0) . ' replies.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('tickets_replies', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `tickets_replies`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('profiles', $rMigrateOptions)) { + $odb->query('SELECT * FROM `profiles`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `profiles`;'); + echo 'Generating ' . number_format(count($rResults), 0) . ' transcoding profiles.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('profiles', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `profiles`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('providers', $rMigrateOptions)) { + $odb->query('SELECT * FROM `providers`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `providers`;'); + echo 'Generating ' . number_format(count($rResults), 0) . ' providers.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('providers', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `providers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('lines', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `lines`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `lines`;'); + echo 'Adding ' . number_format($rCount, 0) . ' lines.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `lines` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult['stream_status'] = 0; + $rResult['stream_started'] = null; + $rResult['monitor_pid'] = null; + if ($rResult['pid'] < 0) { + $rResult['pid'] = null; + } + $rResult = QueryHelper::verifyPostTable('lines', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `lines`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('watch_folders', $rMigrateOptions)) { + $odb->query("SHOW TABLES LIKE 'watch_folders';"); + if ($odb->num_rows() > 0) { + $odb->query('SELECT COUNT(*) AS `count` FROM `watch_folders`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `watch_folders`;'); + echo 'Adding ' . number_format($rCount, 0) . ' folders to watch.' . "\n"; + $odb->query('SELECT * FROM `watch_folders`;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('watch_folders', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `watch_folders`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } + } + } } else { - if (count($rMigrateOptions) == 0) { - $rMigrateOptions = $rTableList; - } - $rItemCount = 0; - foreach ($rTableList as $rTable) { - $odb->query('SHOW TABLES LIKE ?;', $rTable); - if ($odb->num_rows() > 0) { - $odb->query('SELECT COUNT(*) AS `count` FROM `' . $rTable . '`;'); - $rItemCount += (intval($odb->get_row()['count']) ?: 0); - } - } - if ($rItemCount == 0) { - echo "\n" . "Couldn't find anything to migrate in the `xc_vm_migrate` database. Please ensure you restore your backup to that database specifically." . "\n\n"; - exit(); - } - echo "\n" . 'Migrating database to XC_VM...' . "\n\n"; + if (count($rMigrateOptions) == 0) { + $rMigrateOptions = $rTableList; + } + $rItemCount = 0; + foreach ($rTableList as $rTable) { + $odb->query('SHOW TABLES LIKE ?;', $rTable); + if ($odb->num_rows() > 0) { + $odb->query('SELECT COUNT(*) AS `count` FROM `' . $rTable . '`;'); + $rItemCount += (intval($odb->get_row()['count']) ?: 0); + } + } + if ($rItemCount == 0) { + echo "\n" . "Couldn't find anything to migrate in the `xc_vm_migrate` database. Please ensure you restore your backup to that database specifically." . "\n\n"; + exit(); + } + echo "\n" . 'Migrating database to XC_VM...' . "\n\n"; - echo 'Remapping bouquets.' . "\n"; - $rSeriesMap = $rBouquetMap = array(); - $odb->query('SELECT `id`, `type` FROM `streams`;'); - $rStreams = $odb->get_rows(); - foreach ($rStreams as $rStream) { - $rBouquetMap[intval($rStream['id'])] = intval($rStream['type']); - } - $odb->query('SELECT `id` FROM `series`;'); - $rSeries = $odb->get_rows(); - foreach ($rSeries as $rSeriesArr) { - $rSeriesMap[] = intval($rSeriesArr['id']); - } - if (in_array('reg_users', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `reg_users`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `users`;'); - echo 'Adding ' . number_format($rCount, 0) . ' users.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `reg_users` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('users', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `users`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('members', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `members`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `users`;'); - echo 'Adding ' . number_format($rCount, 0) . ' users.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } + echo 'Remapping bouquets.' . "\n"; + $rSeriesMap = $rBouquetMap = []; + $odb->query('SELECT `id`, `type` FROM `streams`;'); + $rStreams = $odb->get_rows(); + foreach ($rStreams as $rStream) { + $rBouquetMap[intval($rStream['id'])] = intval($rStream['type']); + } + $odb->query('SELECT `id` FROM `series`;'); + $rSeries = $odb->get_rows(); + foreach ($rSeries as $rSeriesArr) { + $rSeriesMap[] = intval($rSeriesArr['id']); + } + if (in_array('reg_users', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `reg_users`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `users`;'); + echo 'Adding ' . number_format($rCount, 0) . ' users.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `reg_users` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('users', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `users`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('members', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `members`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `users`;'); + echo 'Adding ' . number_format($rCount, 0) . ' users.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `members` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('users', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `users`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('blocked_ips', $rMigrateOptions)) { - $odb->query('SELECT * FROM `blocked_ips`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `blocked_ips`;'); - echo 'Blocking ' . number_format(count($rResults), 0) . ' IP addresses.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('blocked_ips', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `blocked_ips`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('blocked_user_agents', $rMigrateOptions)) { - $odb->query('SELECT * FROM `blocked_user_agents`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `blocked_uas`;'); - echo 'Blocking ' . number_format(count($rResults), 0) . ' user-agents.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('blocked_uas', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `blocked_uas`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('isp_addon', $rMigrateOptions)) { - $odb->query('SELECT * FROM `isp_addon`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `blocked_isps`;'); - echo 'Blocking ' . number_format(count($rResults), 0) . " ISP's." . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('blocked_isps', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `blocked_isps`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('blocked_isps', $rMigrateOptions)) { - $odb->query('SELECT * FROM `blocked_isps`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `blocked_isps`;'); - echo 'Blocking ' . number_format(count($rResults), 0) . " ISP's." . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('blocked_isps', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `blocked_isps`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('bouquets', $rMigrateOptions)) { - $odb->query('SELECT * FROM `bouquets`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `bouquets`;'); - echo 'Creating ' . number_format(count($rResults), 0) . ' bouquets.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rChannels = json_decode($rResult['bouquet_channels'], true); - $rResult['bouquet_radios'] = array(); - $rResult['bouquet_movies'] = $rResult['bouquet_radios']; - $rResult['bouquet_channels'] = $rResult['bouquet_movies']; - foreach ($rChannels as $rStreamID) { - if (isset($rBouquetMap[intval($rStreamID)])) { - $rType = (array(1 => 'channels', 2 => 'movies', 3 => 'channels', 4 => 'radio')[$rBouquetMap[intval($rStreamID)]] ?? null); - if ($rType) { - $rResult['bouquet_' . $rType][] = intval($rStreamID); - } - } - } - $rSeries = json_decode($rResult['bouquet_series'], true); - $rResult['bouquet_series'] = array(); - foreach ($rSeries as $rSeriesID) { - if (!in_array(intval($rSeriesID), $rSeriesMap)) { - } else { - $rResult['bouquet_series'][] = intval($rSeriesID); - } - } - foreach (array('channels', 'movies', 'radios', 'series') as $rType) { - if ($rResult['bouquet_' . $rType]) { - } else { - $rResult['bouquet_' . $rType] = '[]'; - } - } - $rResult = QueryHelper::verifyPostTable('bouquets', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `bouquets`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('enigma2_devices', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `enigma2_devices`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `enigma2_devices`;'); - echo 'Authorising ' . number_format($rCount, 0) . ' enigma devices.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `members` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('users', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `users`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('blocked_ips', $rMigrateOptions)) { + $odb->query('SELECT * FROM `blocked_ips`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `blocked_ips`;'); + echo 'Blocking ' . number_format(count($rResults), 0) . ' IP addresses.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('blocked_ips', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `blocked_ips`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('blocked_user_agents', $rMigrateOptions)) { + $odb->query('SELECT * FROM `blocked_user_agents`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `blocked_uas`;'); + echo 'Blocking ' . number_format(count($rResults), 0) . ' user-agents.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('blocked_uas', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `blocked_uas`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('isp_addon', $rMigrateOptions)) { + $odb->query('SELECT * FROM `isp_addon`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `blocked_isps`;'); + echo 'Blocking ' . number_format(count($rResults), 0) . " ISP's." . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('blocked_isps', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `blocked_isps`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('blocked_isps', $rMigrateOptions)) { + $odb->query('SELECT * FROM `blocked_isps`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `blocked_isps`;'); + echo 'Blocking ' . number_format(count($rResults), 0) . " ISP's." . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('blocked_isps', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `blocked_isps`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('bouquets', $rMigrateOptions)) { + $odb->query('SELECT * FROM `bouquets`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `bouquets`;'); + echo 'Creating ' . number_format(count($rResults), 0) . ' bouquets.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rChannels = json_decode($rResult['bouquet_channels'], true); + $rResult['bouquet_radios'] = []; + $rResult['bouquet_movies'] = $rResult['bouquet_radios']; + $rResult['bouquet_channels'] = $rResult['bouquet_movies']; + foreach ($rChannels as $rStreamID) { + if (isset($rBouquetMap[intval($rStreamID)])) { + $rType = ([1 => 'channels', 2 => 'movies', 3 => 'channels', 4 => 'radio'][$rBouquetMap[intval($rStreamID)]] ?? null); + if ($rType) { + $rResult['bouquet_' . $rType][] = intval($rStreamID); + } + } + } + $rSeries = json_decode($rResult['bouquet_series'], true); + $rResult['bouquet_series'] = []; + foreach ($rSeries as $rSeriesID) { + if (!in_array(intval($rSeriesID), $rSeriesMap)) { + } else { + $rResult['bouquet_series'][] = intval($rSeriesID); + } + } + foreach (['channels', 'movies', 'radios', 'series'] as $rType) { + if ($rResult['bouquet_' . $rType]) { + } else { + $rResult['bouquet_' . $rType] = '[]'; + } + } + $rResult = QueryHelper::verifyPostTable('bouquets', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `bouquets`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('enigma2_devices', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `enigma2_devices`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `enigma2_devices`;'); + echo 'Authorising ' . number_format($rCount, 0) . ' enigma devices.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `enigma2_devices` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult['lock_device'] = 1; - $rResult = QueryHelper::verifyPostTable('enigma2_devices', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `enigma2_devices`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('mag_devices', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `mag_devices`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `mag_devices`;'); - echo 'Authorising ' . number_format($rCount, 0) . ' MAG devices.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `enigma2_devices` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult['lock_device'] = 1; + $rResult = QueryHelper::verifyPostTable('enigma2_devices', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `enigma2_devices`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('mag_devices', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `mag_devices`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `mag_devices`;'); + echo 'Authorising ' . number_format($rCount, 0) . ' MAG devices.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `mag_devices` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult['mac'] = base64_decode($rResult['mac']); - $rResult['lock_device'] = 1; - if (0 >= $rResult['user_id']) { - } else { - $rResult = QueryHelper::verifyPostTable('mag_devices', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `mag_devices`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('epg', $rMigrateOptions)) { - $odb->query('SELECT * FROM `epg`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `epg`;'); - echo 'Processing ' . number_format(count($rResults), 0) . ' EPG URLs.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('epg', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `epg`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('epg_sources', $rMigrateOptions)) { - $odb->query('SELECT * FROM `epg_sources`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `epg`;'); - echo 'Processing ' . number_format(count($rResults), 0) . ' EPG URLs.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('epg', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `epg`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('member_groups', $rMigrateOptions)) { - $odb->query('SELECT * FROM `member_groups` WHERE `can_delete` = 1;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('DELETE FROM `users_groups` WHERE `can_delete` = 1;'); - echo 'Creating ' . number_format(count($rResults), 0) . ' user groups.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult['can_view_vod'] = $rResult['reset_stb_data']; - $rResult['allow_restrictions'] = 1; - $rResult['allow_change_username'] = 1; - $rResult['allow_change_password'] = 1; - $rResult['minimum_username_length'] = 8; - $rResult['minimum_password_length'] = 8; - $rResult = QueryHelper::verifyPostTable('users_groups', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `users_groups`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('groups', $rMigrateOptions)) { - $odb->query('SELECT * FROM `groups` WHERE `can_delete` = 1;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('DELETE FROM `users_groups` WHERE `can_delete` = 1;'); - echo 'Creating ' . number_format(count($rResults), 0) . ' user groups.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult['can_view_vod'] = $rResult['reset_stb_data']; - $rResult['allow_restrictions'] = 1; - $rResult['allow_change_username'] = 1; - $rResult['allow_change_password'] = 1; - $rResult['minimum_username_length'] = 8; - $rResult['minimum_password_length'] = 8; - $rResult = QueryHelper::verifyPostTable('users_groups', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `users_groups`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('groups', $rMigrateOptions)) { - $odb->query('SELECT * FROM `groups` WHERE `can_delete` = 1;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('DELETE FROM `users_groups` WHERE `can_delete` = 1;'); - echo 'Creating ' . number_format(count($rResults), 0) . ' user groups.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult['can_view_vod'] = $rResult['reset_stb_data']; - $rResult['allow_restrictions'] = 1; - $rResult['allow_change_username'] = 1; - $rResult['allow_change_password'] = 1; - $rResult['minimum_username_length'] = 8; - $rResult['minimum_password_length'] = 8; - $rResult = QueryHelper::verifyPostTable('users_groups', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `users_groups`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('packages', $rMigrateOptions)) { - $odb->query('SELECT * FROM `packages`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `users_packages`;'); - echo 'Creating ' . number_format(count($rResults), 0) . ' user packages.' . "\n"; - foreach ($rResults as $rResult) { - try { - if ($rResult['can_gen_mag']) { - $rResult['is_mag'] = 1; - } else { - $rResult['is_mag'] = 0; - } - if ($rResult['can_gen_e2']) { - $rResult['is_e2'] = 1; - } else { - $rResult['is_e2'] = 0; - } - if ($rResult['only_mag'] || $rResult['only_e2']) { - $rResult['is_line'] = 0; - } else { - $rResult['is_line'] = 1; - } - $rResult['lock_device'] = 1; - $rResult['check_compatible'] = 1; - if (count(json_decode($rResult['output_formats'], true)) != 0) { - } else { - $rResult['output_formats'] = '[1,2,3]'; - } - $rResult = QueryHelper::verifyPostTable('users_packages', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `users_packages`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('rtmp_ips', $rMigrateOptions)) { - $odb->query('SELECT * FROM `rtmp_ips`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `rtmp_ips`;'); - echo 'Authorising ' . number_format(count($rResults), 0) . ' RTMP IPs.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('rtmp_ips', $rResult); - $rResult['push'] = 1; - $rResult['pull'] = 1; - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `rtmp_ips`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('series', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `series`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `streams_series`;'); - echo 'Adding ' . number_format($rCount, 0) . ' TV series.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `mag_devices` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult['mac'] = base64_decode($rResult['mac']); + $rResult['lock_device'] = 1; + if (0 >= $rResult['user_id']) { + } else { + $rResult = QueryHelper::verifyPostTable('mag_devices', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `mag_devices`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('epg', $rMigrateOptions)) { + $odb->query('SELECT * FROM `epg`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `epg`;'); + echo 'Processing ' . number_format(count($rResults), 0) . ' EPG URLs.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('epg', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `epg`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('epg_sources', $rMigrateOptions)) { + $odb->query('SELECT * FROM `epg_sources`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `epg`;'); + echo 'Processing ' . number_format(count($rResults), 0) . ' EPG URLs.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('epg', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `epg`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('member_groups', $rMigrateOptions)) { + $odb->query('SELECT * FROM `member_groups` WHERE `can_delete` = 1;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('DELETE FROM `users_groups` WHERE `can_delete` = 1;'); + echo 'Creating ' . number_format(count($rResults), 0) . ' user groups.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult['can_view_vod'] = $rResult['reset_stb_data']; + $rResult['allow_restrictions'] = 1; + $rResult['allow_change_username'] = 1; + $rResult['allow_change_password'] = 1; + $rResult['minimum_username_length'] = 8; + $rResult['minimum_password_length'] = 8; + $rResult = QueryHelper::verifyPostTable('users_groups', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `users_groups`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('groups', $rMigrateOptions)) { + $odb->query('SELECT * FROM `groups` WHERE `can_delete` = 1;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('DELETE FROM `users_groups` WHERE `can_delete` = 1;'); + echo 'Creating ' . number_format(count($rResults), 0) . ' user groups.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult['can_view_vod'] = $rResult['reset_stb_data']; + $rResult['allow_restrictions'] = 1; + $rResult['allow_change_username'] = 1; + $rResult['allow_change_password'] = 1; + $rResult['minimum_username_length'] = 8; + $rResult['minimum_password_length'] = 8; + $rResult = QueryHelper::verifyPostTable('users_groups', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `users_groups`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('groups', $rMigrateOptions)) { + $odb->query('SELECT * FROM `groups` WHERE `can_delete` = 1;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('DELETE FROM `users_groups` WHERE `can_delete` = 1;'); + echo 'Creating ' . number_format(count($rResults), 0) . ' user groups.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult['can_view_vod'] = $rResult['reset_stb_data']; + $rResult['allow_restrictions'] = 1; + $rResult['allow_change_username'] = 1; + $rResult['allow_change_password'] = 1; + $rResult['minimum_username_length'] = 8; + $rResult['minimum_password_length'] = 8; + $rResult = QueryHelper::verifyPostTable('users_groups', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `users_groups`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('packages', $rMigrateOptions)) { + $odb->query('SELECT * FROM `packages`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `users_packages`;'); + echo 'Creating ' . number_format(count($rResults), 0) . ' user packages.' . "\n"; + foreach ($rResults as $rResult) { + try { + if ($rResult['can_gen_mag']) { + $rResult['is_mag'] = 1; + } else { + $rResult['is_mag'] = 0; + } + if ($rResult['can_gen_e2']) { + $rResult['is_e2'] = 1; + } else { + $rResult['is_e2'] = 0; + } + if ($rResult['only_mag'] || $rResult['only_e2']) { + $rResult['is_line'] = 0; + } else { + $rResult['is_line'] = 1; + } + $rResult['lock_device'] = 1; + $rResult['check_compatible'] = 1; + if (count(json_decode($rResult['output_formats'], true)) != 0) { + } else { + $rResult['output_formats'] = '[1,2,3]'; + } + $rResult = QueryHelper::verifyPostTable('users_packages', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `users_packages`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('rtmp_ips', $rMigrateOptions)) { + $odb->query('SELECT * FROM `rtmp_ips`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `rtmp_ips`;'); + echo 'Authorising ' . number_format(count($rResults), 0) . ' RTMP IPs.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('rtmp_ips', $rResult); + $rResult['push'] = 1; + $rResult['pull'] = 1; + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `rtmp_ips`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('series', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `series`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `streams_series`;'); + echo 'Adding ' . number_format($rCount, 0) . ' TV series.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `series` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult['category_id'] = '[' . intval($rResult['category_id']) . ']'; - $rResult['release_date'] = $rResult['releaseDate']; - if ($rResult['tmdb_id'] != 0) { - } else { - $rResult['tmdb_id'] = null; - } - $rResult = QueryHelper::verifyPostTable('streams_series', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT IGNORE INTO `streams_series`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('series_episodes', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `series_episodes`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `streams_episodes`;'); - echo 'Adding ' . number_format($rCount, 0) . ' episodes.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `series` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult['category_id'] = '[' . intval($rResult['category_id']) . ']'; + $rResult['release_date'] = $rResult['releaseDate']; + if ($rResult['tmdb_id'] != 0) { + } else { + $rResult['tmdb_id'] = null; + } + $rResult = QueryHelper::verifyPostTable('streams_series', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT IGNORE INTO `streams_series`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('series_episodes', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `series_episodes`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `streams_episodes`;'); + echo 'Adding ' . number_format($rCount, 0) . ' episodes.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `series_episodes` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult['episode_num'] = $rResult['sort']; - $rResult = QueryHelper::verifyPostTable('streams_episodes', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams_episodes`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('streaming_servers', $rMigrateOptions)) { - $odb->query('SELECT * FROM `streaming_servers`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $rMain = false; - $db->query('TRUNCATE `servers`;'); - echo 'Moving ' . number_format(count($rResults), 0) . ' servers.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult['server_type'] = 0; - $rResult['parent_id'] = null; - $rResult['http_broadcast_port'] = 80; - $rResult['https_broadcast_port'] = 443; - $rResult['rtmp_port'] = 8880; - $rResult['total_services'] = 4; - $rResult['http_ports_add'] = null; - $rResult['https_ports_add'] = null; - if ($rResult['can_delete'] == 0 && !$rMain) { - $rResult['is_main'] = 1; - $rMain = true; - } else { - $rResult['is_main'] = 0; - } - $rResult = QueryHelper::verifyPostTable('servers', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('servers', $rMigrateOptions)) { - $odb->query('SELECT * FROM `servers` ORDER BY `id` ASC;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $rMain = false; - $db->query('TRUNCATE `servers`;'); - echo 'Moving ' . number_format(count($rResults), 0) . ' servers.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult['server_type'] = 0; - $rResult['parent_id'] = null; - $rResult['http_broadcast_port'] = 80; - $rResult['https_broadcast_port'] = 443; - $rResult['rtmp_port'] = 8880; - $rResult['total_services'] = 4; - $rResult['http_ports_add'] = null; - $rResult['https_ports_add'] = null; - if (!$rMain) { - $rResult['is_main'] = 1; - $rMain = true; - } else { - $rResult['is_main'] = 0; - } - $rResult = QueryHelper::verifyPostTable('servers', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - $rCreatedOptions = array(); - if (in_array('streams', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `streams`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `streams`;'); - echo 'Adding ' . number_format($rCount, 0) . ' streams.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `series_episodes` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult['episode_num'] = $rResult['sort']; + $rResult = QueryHelper::verifyPostTable('streams_episodes', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams_episodes`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('streaming_servers', $rMigrateOptions)) { + $odb->query('SELECT * FROM `streaming_servers`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $rMain = false; + $db->query('TRUNCATE `servers`;'); + echo 'Moving ' . number_format(count($rResults), 0) . ' servers.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult['server_type'] = 0; + $rResult['parent_id'] = null; + $rResult['http_broadcast_port'] = 80; + $rResult['https_broadcast_port'] = 443; + $rResult['rtmp_port'] = 8880; + $rResult['total_services'] = 4; + $rResult['http_ports_add'] = null; + $rResult['https_ports_add'] = null; + if ($rResult['can_delete'] == 0 && !$rMain) { + $rResult['is_main'] = 1; + $rMain = true; + } else { + $rResult['is_main'] = 0; + } + $rResult = QueryHelper::verifyPostTable('servers', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('servers', $rMigrateOptions)) { + $odb->query('SELECT * FROM `servers` ORDER BY `id` ASC;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $rMain = false; + $db->query('TRUNCATE `servers`;'); + echo 'Moving ' . number_format(count($rResults), 0) . ' servers.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult['server_type'] = 0; + $rResult['parent_id'] = null; + $rResult['http_broadcast_port'] = 80; + $rResult['https_broadcast_port'] = 443; + $rResult['rtmp_port'] = 8880; + $rResult['total_services'] = 4; + $rResult['http_ports_add'] = null; + $rResult['https_ports_add'] = null; + if (!$rMain) { + $rResult['is_main'] = 1; + $rMain = true; + } else { + $rResult['is_main'] = 0; + } + $rResult = QueryHelper::verifyPostTable('servers', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + $rCreatedOptions = []; + if (in_array('streams', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `streams`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `streams`;'); + echo 'Adding ' . number_format($rCount, 0) . ' streams.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `streams` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - try { - $rExternal = json_decode($rResult['external_push'], true); - if ($rExternal) { - } else { - $rResult['external_push'] = '{}'; - } - $rResult['category_id'] = '[' . intval($rResult['category_id']) . ']'; - $rResult['movie_properties'] = $rResult['movie_propeties']; - if (!$rResult['target_container']) { - } else { - list($rResult['target_container']) = json_decode($rResult['target_container'], true); - } - $rCreatedOptions[$rResult['id']] = $rResult['cchannel_rsources']; - $rResult = QueryHelper::verifyPostTable('streams', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('streams_options', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `streams_options`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `streams_options`;'); - echo 'Attributing ' . number_format($rCount, 0) . ' options to streams.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `streams` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + try { + $rExternal = json_decode($rResult['external_push'], true); + if ($rExternal) { + } else { + $rResult['external_push'] = '{}'; + } + $rResult['category_id'] = '[' . intval($rResult['category_id']) . ']'; + $rResult['movie_properties'] = $rResult['movie_propeties']; + if (!$rResult['target_container']) { + } else { + list($rResult['target_container']) = json_decode($rResult['target_container'], true); + } + $rCreatedOptions[$rResult['id']] = $rResult['cchannel_rsources']; + $rResult = QueryHelper::verifyPostTable('streams', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('streams_options', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `streams_options`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `streams_options`;'); + echo 'Attributing ' . number_format($rCount, 0) . ' options to streams.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `streams_options` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('streams_options', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams_options`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('streams_sys', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `streams_sys`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `streams_servers`;'); - echo 'Allocating ' . number_format($rCount, 0) . ' streams to servers.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `streams_options` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('streams_options', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams_options`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('streams_sys', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `streams_sys`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `streams_servers`;'); + echo 'Allocating ' . number_format($rCount, 0) . ' streams to servers.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `streams_sys` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - if (0 >= count($rResults)) { - } else { - foreach ($rResults as $rResult) { - if ($rResult['parent_id'] && $rResult['parent_id'] != 0) { - } else { - $rResult['parent_id'] = null; - } - if (!isset($rCreatedOptions[$rResult['stream_id']])) { - } else { - $rResult['cchannel_rsources'] = $rCreatedOptions[$rResult['stream_id']]; - } - $rResult['custom_ffmpeg'] = ''; - $rResult['stream_status'] = 0; - $rResult['stream_started'] = null; - $rResult['monitor_pid'] = null; - if ($rResult['pid'] < 0) { - $rResult['pid'] = null; - } - $rResult = QueryHelper::verifyPostTable('streams_servers', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams_servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('stream_servers', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `stream_servers`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `streams_servers`;'); - echo 'Allocating ' . number_format($rCount, 0) . ' streams to servers.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `streams_sys` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + if (0 >= count($rResults)) { + } else { + foreach ($rResults as $rResult) { + if ($rResult['parent_id'] && $rResult['parent_id'] != 0) { + } else { + $rResult['parent_id'] = null; + } + if (!isset($rCreatedOptions[$rResult['stream_id']])) { + } else { + $rResult['cchannel_rsources'] = $rCreatedOptions[$rResult['stream_id']]; + } + $rResult['custom_ffmpeg'] = ''; + $rResult['stream_status'] = 0; + $rResult['stream_started'] = null; + $rResult['monitor_pid'] = null; + if ($rResult['pid'] < 0) { + $rResult['pid'] = null; + } + $rResult = QueryHelper::verifyPostTable('streams_servers', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams_servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('stream_servers', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `stream_servers`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `streams_servers`;'); + echo 'Allocating ' . number_format($rCount, 0) . ' streams to servers.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `stream_servers` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - if (0 >= count($rResults)) { - } else { - foreach ($rResults as $rResult) { - if ($rResult['parent_id'] && $rResult['parent_id'] != 0) { - } else { - $rResult['parent_id'] = null; - } - $rResult['stream_status'] = 0; - $rResult['stream_started'] = null; - $rResult['monitor_pid'] = null; - if ($rResult['pid'] > 0) { - } else { - $rResult['pid'] = null; - } - $rResult = QueryHelper::verifyPostTable('streams_servers', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams_servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('stream_categories', $rMigrateOptions)) { - $odb->query('SELECT * FROM `stream_categories`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `streams_categories`;'); - echo 'Creating ' . number_format(count($rResults), 0) . ' categories.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('streams_categories', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams_categories`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('categories', $rMigrateOptions)) { - $odb->query('SELECT * FROM `categories`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `streams_categories`;'); - echo 'Creating ' . number_format(count($rResults), 0) . ' categories.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('streams_categories', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `streams_categories`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('tickets', $rMigrateOptions)) { - $odb->query('SELECT * FROM `tickets`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `tickets`;'); - echo 'Posting ' . number_format(count($rResults), 0) . ' tickets.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('tickets', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `tickets`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('tickets_replies', $rMigrateOptions)) { - $odb->query('SELECT * FROM `tickets_replies`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `tickets_replies`;'); - echo 'Posting ' . number_format(count($rResults), 0) . ' replies.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('tickets_replies', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `tickets_replies`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('transcoding_profiles', $rMigrateOptions)) { - $odb->query('SELECT * FROM `transcoding_profiles`;'); - $rResults = $odb->get_rows(); - if (count($rResults) > 0) { - $db->query('TRUNCATE `profiles`;'); - echo 'Generating ' . number_format(count($rResults), 0) . ' transcoding profiles.' . "\n"; - foreach ($rResults as $rResult) { - try { - $rResult = QueryHelper::verifyPostTable('profiles', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `profiles`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - $rOutput = array(); - if (in_array('user_output', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `user_output`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - echo 'Attributing ' . number_format($rCount, 0) . ' output options to lines.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `stream_servers` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + if (0 >= count($rResults)) { + } else { + foreach ($rResults as $rResult) { + if ($rResult['parent_id'] && $rResult['parent_id'] != 0) { + } else { + $rResult['parent_id'] = null; + } + $rResult['stream_status'] = 0; + $rResult['stream_started'] = null; + $rResult['monitor_pid'] = null; + if ($rResult['pid'] > 0) { + } else { + $rResult['pid'] = null; + } + $rResult = QueryHelper::verifyPostTable('streams_servers', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams_servers`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('stream_categories', $rMigrateOptions)) { + $odb->query('SELECT * FROM `stream_categories`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `streams_categories`;'); + echo 'Creating ' . number_format(count($rResults), 0) . ' categories.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('streams_categories', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams_categories`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('categories', $rMigrateOptions)) { + $odb->query('SELECT * FROM `categories`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `streams_categories`;'); + echo 'Creating ' . number_format(count($rResults), 0) . ' categories.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('streams_categories', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `streams_categories`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('tickets', $rMigrateOptions)) { + $odb->query('SELECT * FROM `tickets`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `tickets`;'); + echo 'Posting ' . number_format(count($rResults), 0) . ' tickets.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('tickets', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `tickets`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('tickets_replies', $rMigrateOptions)) { + $odb->query('SELECT * FROM `tickets_replies`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `tickets_replies`;'); + echo 'Posting ' . number_format(count($rResults), 0) . ' replies.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('tickets_replies', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `tickets_replies`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('transcoding_profiles', $rMigrateOptions)) { + $odb->query('SELECT * FROM `transcoding_profiles`;'); + $rResults = $odb->get_rows(); + if (count($rResults) > 0) { + $db->query('TRUNCATE `profiles`;'); + echo 'Generating ' . number_format(count($rResults), 0) . ' transcoding profiles.' . "\n"; + foreach ($rResults as $rResult) { + try { + $rResult = QueryHelper::verifyPostTable('profiles', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `profiles`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + $rOutput = []; + if (in_array('user_output', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `user_output`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + echo 'Attributing ' . number_format($rCount, 0) . ' output options to lines.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `user_output` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rOutput[$rResult['user_id']][] = $rResult['access_output_id']; - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('users', $rMigrateOptions)) { - $odb->query('SELECT COUNT(*) AS `count` FROM `users`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `lines`;'); - echo 'Adding ' . number_format($rCount, 0) . ' lines.' . "\n"; - $rSteps = []; - $stepSize = 1000; - for ($i = 0; $i < $rCount; $i += $stepSize) { - $rSteps[] = $i; - } - if (empty($rSteps)) { - $rSteps = [0]; - } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `user_output` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rOutput[$rResult['user_id']][] = $rResult['access_output_id']; + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('users', $rMigrateOptions)) { + $odb->query('SELECT COUNT(*) AS `count` FROM `users`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `lines`;'); + echo 'Adding ' . number_format($rCount, 0) . ' lines.' . "\n"; + $rSteps = []; + $stepSize = 1000; + for ($i = 0; $i < $rCount; $i += $stepSize) { + $rSteps[] = $i; + } + if (empty($rSteps)) { + $rSteps = [0]; + } - foreach ($rSteps as $rStep) { - try { - $odb->query('SELECT * FROM `users` LIMIT ' . $rStep . ', 1000;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - if (!empty($rResult['isp_desc'])) { - } else { - $rResult['isp_desc'] = null; - } - if (!isset($rOutput[$rResult['id']])) { - } else { - $rResult['allowed_outputs'] = '[' . implode(',', $rOutput[$rResult['id']]) . ']'; - } - if (!isset($rResult['output'])) { - } else { - $rResult['allowed_outputs'] = $rResult['output']; - } - $rResult['bouquet'] = '[' . implode(',', array_map('intval', json_decode($rResult['bouquet'], true))) . ']'; - $rResult = QueryHelper::verifyPostTable('lines', $rResult); - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `lines`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; - } - } - } - } - if (in_array('watch_folders', $rMigrateOptions)) { - $odb->query("SHOW TABLES LIKE 'watch_folders';"); - if (0 >= $odb->num_rows()) { - } else { - $odb->query('SELECT COUNT(*) AS `count` FROM `watch_folders`;'); - $rCount = $odb->get_row()['count']; - if ($rCount > 0) { - $db->query('TRUNCATE `watch_folders`;'); - echo 'Adding ' . number_format($rCount, 0) . ' folders to watch.' . "\n"; - $odb->query('SELECT * FROM `watch_folders`;'); - $rResults = $odb->get_rows(); - foreach ($rResults as $rResult) { - $rResult = QueryHelper::verifyPostTable('watch_folders', $rResult); - $rResult['bouquets'] = '[' . implode(',', array_map('intval', json_decode($rResult['bouquets'], true))) . ']'; - $rResult['fb_bouquets'] = '[' . implode(',', array_map('intval', json_decode($rResult['fb_bouquets'], true))) . ']'; - $rPrepare = QueryHelper::prepareArray($rResult); - $rQuery = 'INSERT INTO `watch_folders`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; - $db->query($rQuery, ...$rPrepare['data']); - } - } - } - } + foreach ($rSteps as $rStep) { + try { + $odb->query('SELECT * FROM `users` LIMIT ' . $rStep . ', 1000;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + if (!empty($rResult['isp_desc'])) { + } else { + $rResult['isp_desc'] = null; + } + if (!isset($rOutput[$rResult['id']])) { + } else { + $rResult['allowed_outputs'] = '[' . implode(',', $rOutput[$rResult['id']]) . ']'; + } + if (!isset($rResult['output'])) { + } else { + $rResult['allowed_outputs'] = $rResult['output']; + } + $rResult['bouquet'] = '[' . implode(',', array_map('intval', json_decode($rResult['bouquet'], true))) . ']'; + $rResult = QueryHelper::verifyPostTable('lines', $rResult); + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `lines`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } catch (\Exception $e) { + echo 'Error: ' . $e . "\n"; + } + } + } + } + if (in_array('watch_folders', $rMigrateOptions)) { + $odb->query("SHOW TABLES LIKE 'watch_folders';"); + if (0 >= $odb->num_rows()) { + } else { + $odb->query('SELECT COUNT(*) AS `count` FROM `watch_folders`;'); + $rCount = $odb->get_row()['count']; + if ($rCount > 0) { + $db->query('TRUNCATE `watch_folders`;'); + echo 'Adding ' . number_format($rCount, 0) . ' folders to watch.' . "\n"; + $odb->query('SELECT * FROM `watch_folders`;'); + $rResults = $odb->get_rows(); + foreach ($rResults as $rResult) { + $rResult = QueryHelper::verifyPostTable('watch_folders', $rResult); + $rResult['bouquets'] = '[' . implode(',', array_map('intval', json_decode($rResult['bouquets'], true))) . ']'; + $rResult['fb_bouquets'] = '[' . implode(',', array_map('intval', json_decode($rResult['fb_bouquets'], true))) . ']'; + $rPrepare = QueryHelper::prepareArray($rResult); + $rQuery = 'INSERT INTO `watch_folders`(' . $rPrepare['columns'] . ') VALUES(' . $rPrepare['placeholder'] . ');'; + $db->query($rQuery, ...$rPrepare['data']); + } + } + } + } } try { - $odb->query('SELECT * FROM `settings` LIMIT 1;'); - $rSettings = $odb->get_row(); - $db->query('UPDATE `settings` SET `server_name` = ?, `default_timezone` = ?;', $rSettings['server_name'], $rSettings['default_timezone']); + $odb->query('SELECT * FROM `settings` LIMIT 1;'); + $rSettings = $odb->get_row(); + $db->query('UPDATE `settings` SET `server_name` = ?, `default_timezone` = ?;', $rSettings['server_name'], $rSettings['default_timezone']); } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; + echo 'Error: ' . $e . "\n"; } try { - $odb->query("SHOW TABLES LIKE 'admin_settings';"); - if ($odb->num_rows() > 0) { - $rAdminSettings = array(); - $odb->query('SELECT * FROM `admin_settings`;'); - foreach ($odb->get_rows() as $rRow) { - $rAdminSettings[$rRow['type']] = $rRow['value']; - } - if (!(0 < strlen($rAdminSettings['recaptcha_v2_secret_key']) && 0 < strlen($rAdminSettings['recaptcha_v2_site_key']))) { - } else { - $db->query('UPDATE `settings` SET `recaptcha_v2_secret_key` = ?, `recaptcha_v2_site_key` = ?;', $rAdminSettings['recaptcha_v2_secret_key'], $rAdminSettings['recaptcha_v2_site_key']); - } - } + $odb->query("SHOW TABLES LIKE 'admin_settings';"); + if ($odb->num_rows() > 0) { + $rAdminSettings = []; + $odb->query('SELECT * FROM `admin_settings`;'); + foreach ($odb->get_rows() as $rRow) { + $rAdminSettings[$rRow['type']] = $rRow['value']; + } + if (!(0 < strlen($rAdminSettings['recaptcha_v2_secret_key']) && 0 < strlen($rAdminSettings['recaptcha_v2_site_key']))) { + } else { + $db->query('UPDATE `settings` SET `recaptcha_v2_secret_key` = ?, `recaptcha_v2_site_key` = ?;', $rAdminSettings['recaptcha_v2_secret_key'], $rAdminSettings['recaptcha_v2_site_key']); + } + } } catch (\Exception $e) { - echo 'Error: ' . $e . "\n"; + echo 'Error: ' . $e . "\n"; } if (in_array('access_codes', $rMigrateOptions)) { - echo "\n" . 'Admin acces code: ' . $AdminAccesCode; + echo "\n" . 'Admin acces code: ' . $AdminAccesCode; } echo "\n" . 'Migration has been completed!' . "\n\n" . 'Your settings have been reset to the XC_VM default, please take some time to review the settings page and make the desired changes.' . "\n"; diff --git a/src/Core/Auth/AuthRepository.php b/src/Core/Auth/AuthRepository.php index 134b6cc0..f6817117 100644 --- a/src/Core/Auth/AuthRepository.php +++ b/src/Core/Auth/AuthRepository.php @@ -23,9 +23,9 @@ class AuthRepository { * @param int|null $rType Access-code type to filter by, or null for all. * @return array Rows keyed by access-code id. */ - public static function getAllCodes($rType = null) { + public static function getAllCodes(?int $rType = null) { global $db; - $rReturn = array(); + $rReturn = []; if (!is_null($rType)) { $db->query('SELECT * FROM `access_codes` WHERE `type` = ? ORDER BY `id` ASC;', $rType); @@ -48,8 +48,8 @@ class AuthRepository { * @param string $rMainHome Panel home path (trailing slash). * @return string[] Access-code names (config filenames without extension, excluding 'default'). */ - public static function getActiveCodes($rMainHome) { - $rCodes = array(); + public static function getActiveCodes(string $rMainHome) { + $rCodes = []; $rFiles = scandir($rMainHome . 'bin/nginx/conf/codes/'); foreach ($rFiles as $rFile) { @@ -72,7 +72,7 @@ class AuthRepository { public static function getWebPlayerCode(): ?string { foreach (self::getAllCodes(6) as $code) { if (!empty($code['enabled'])) { - return (string)$code['code']; + return (string) $code['code']; } } return null; @@ -86,7 +86,7 @@ class AuthRepository { public static function getActiveCodePortalCode(): ?string { foreach (self::getAllCodes(7) as $code) { if (!empty($code['enabled'])) { - return (string)$code['code']; + return (string) $code['code']; } } return null; @@ -109,7 +109,7 @@ class AuthRepository { foreach (self::getAllCodes() as $rCode) { if ($rCode['enabled']) { - $rWhitelist = array(); + $rWhitelist = []; foreach ((array) json_decode($rCode['whitelist'], true) as $rIP) { if (filter_var($rIP, FILTER_VALIDATE_IP)) { @@ -128,22 +128,22 @@ class AuthRepository { $rAliasMap = [0 => 'Public/Views/admin', 1 => 'reseller', 2 => 'Ministra', 3 => 'includes/api/admin', 4 => 'includes/api/reseller', 5 => 'Ministra/new', 6 => 'Public/assets/player', 7 => 'Public/Views/portal']; $rBurstMap = [0 => 500, 1 => 50, 2 => 50, 3 => 1000, 4 => 1000, 5 => 50, 6 => 500, 7 => 500]; - $rType = $rTypeMap[(int)$rCode['type']] ?? 'admin'; - $rAlias = $rAliasMap[(int)$rCode['type']] ?? 'Public/Views/admin'; - $rBurst = $rBurstMap[(int)$rCode['type']] ?? 500; - $rCurrentTemplate = in_array($rType, array('ministra', 'ministra/new')) ? $rMinistraTemplate : $rTemplate; + $rType = $rTypeMap[(int) $rCode['type']] ?? 'admin'; + $rAlias = $rAliasMap[(int) $rCode['type']] ?? 'Public/Views/admin'; + $rBurst = $rBurstMap[(int) $rCode['type']] ?? 500; + $rCurrentTemplate = in_array($rType, ['ministra', 'ministra/new']) ? $rMinistraTemplate : $rTemplate; - if (in_array($rType, array('ministra', 'ministra/new')) || strlen($rCode['code']) >= 4) { - file_put_contents($rMainHome . 'bin/nginx/conf/codes/' . $rCode['code'] . '.conf', str_replace(array('#WHITELIST#', '#CODE#', '#TYPE#', '#BURST#', '#ALIAS#'), array(implode(' ', $rWhitelist), (string) $rCode['code'], $rType, (string) $rBurst, $rAlias), $rCurrentTemplate)); + if (in_array($rType, ['ministra', 'ministra/new']) || strlen($rCode['code']) >= 4) { + file_put_contents($rMainHome . 'bin/nginx/conf/codes/' . $rCode['code'] . '.conf', str_replace(['#WHITELIST#', '#CODE#', '#TYPE#', '#BURST#', '#ALIAS#'], [implode(' ', $rWhitelist), (string) $rCode['code'], $rType, (string) $rBurst, $rAlias], $rCurrentTemplate)); } else { - file_put_contents($rMainHome . 'bin/nginx/conf/codes/' . $rCode['code'] . '.conf', str_replace(array('#WHITELIST#', '#CODE#', '#TYPE#', '#BURST#', '#ALIAS#'), array(implode(' ', $rWhitelist), $rCode['code'] . '/', $rType . '/', (string) $rBurst, $rAlias . '/'), $rCurrentTemplate)); + file_put_contents($rMainHome . 'bin/nginx/conf/codes/' . $rCode['code'] . '.conf', str_replace(['#WHITELIST#', '#CODE#', '#TYPE#', '#BURST#', '#ALIAS#'], [implode(' ', $rWhitelist), $rCode['code'] . '/', $rType . '/', (string) $rBurst, $rAlias . '/'], $rCurrentTemplate)); } } } if (count(self::getActiveCodes($rMainHome)) == 0) { if (!file_exists($rMainHome . 'bin/nginx/conf/codes/default.conf')) { - file_put_contents($rMainHome . 'bin/nginx/conf/codes/default.conf', str_replace(array('alias ', '#WHITELIST#', '#CODE#', '#TYPE#', '#ALIAS#'), array('root ', '', '', 'admin', 'Public/Views/admin'), $rTemplate)); + file_put_contents($rMainHome . 'bin/nginx/conf/codes/default.conf', str_replace(['alias ', '#WHITELIST#', '#CODE#', '#TYPE#', '#ALIAS#'], ['root ', '', '', 'admin', 'Public/Views/admin'], $rTemplate)); } } else { if (file_exists($rMainHome . 'bin/nginx/conf/codes/default.conf')) { @@ -151,7 +151,7 @@ class AuthRepository { } } - ApiClient::systemRequest($rServerId, array('action' => 'reload_nginx')); + ApiClient::systemRequest($rServerId, ['action' => 'reload_nginx']); } /** @@ -163,7 +163,7 @@ class AuthRepository { * @param bool $rInfo When true, return the full access-code DB row instead of the code string. * @return string|array|null Code string, or the DB row when $rInfo is true (null if not found). */ - public static function getCurrentCode($rInfo = false) { + public static function getCurrentCode(bool $rInfo = false) { global $db; // Front Controller передаёт XC_CODE через fastcgi_param. // Без FC — определяем из PHP_SELF (legacy поведение). @@ -189,7 +189,7 @@ class AuthRepository { */ public static function getAllHMAC() { global $db; - $rReturn = array(); + $rReturn = []; $db->query('SELECT * FROM `hmac_keys` ORDER BY `id` ASC;'); if ($db->num_rows() > 0) { @@ -207,7 +207,7 @@ class AuthRepository { * @param int $rID HMAC key id. * @return array|null The key row, or null if not found. */ - public static function getHMACById($rID) { + public static function getHMACById(int $rID) { global $db; $db->query('SELECT * FROM `hmac_keys` WHERE `id` = ?;', $rID); if ($db->num_rows() == 1) { @@ -228,7 +228,7 @@ class AuthRepository { * @param int $rID User group id. * @return array The group permissions row, or [] if not found. */ - public static function getPermissions($rID) { + public static function getPermissions(int $rID) { global $db; $db->query('SELECT * FROM `users_groups` WHERE `group_id` = ?;', $rID); @@ -257,9 +257,9 @@ class AuthRepository { * @param bool $rUsers When true, include sub-users and report maps. * @return array Effective permissions (create flags, stream/series/category ids, users, reports). */ - public static function getGroupPermissions($rUserID, $rStreams = true, $rUsers = true) { + public static function getGroupPermissions(int $rUserID, bool $rStreams = true, bool $rUsers = true) { global $db; - $rReturn = array('create_line' => false, 'create_mag' => false, 'create_enigma' => false, 'stream_ids' => array(), 'series_ids' => array(), 'category_ids' => array(), 'users' => array(), 'direct_reports' => array(), 'all_reports' => array(), 'report_map' => array()); + $rReturn = ['create_line' => false, 'create_mag' => false, 'create_enigma' => false, 'stream_ids' => [], 'series_ids' => [], 'category_ids' => [], 'users' => [], 'direct_reports' => [], 'all_reports' => [], 'report_map' => []]; $rUser = UserRepository::getRegisteredUserById($rUserID); if (!$rUser) { @@ -315,7 +315,7 @@ class AuthRepository { * @param int $rID Access-code id. * @return array|null The access-code row, or null if not found. */ - public static function getCodeById($rID) { + public static function getCodeById(int $rID) { global $db; $db->query('SELECT * FROM `access_codes` WHERE `id` = ?;', $rID); @@ -332,7 +332,7 @@ class AuthRepository { * @param int $rID Access-code id. * @return bool True on deletion, false if the code does not exist. */ - public static function deleteCode($rID) { + public static function deleteCode(int $rID) { global $db; $db->query('SELECT `id` FROM `access_codes` WHERE `id` = ?;', $rID); @@ -352,7 +352,7 @@ class AuthRepository { * @param int $rID HMAC key id. * @return bool True on deletion, false if the key does not exist. */ - public static function deleteHMAC($rID) { + public static function deleteHMAC(int $rID) { global $db; $db->query('SELECT `id` FROM `hmac_keys` WHERE `id` = ?;', $rID); diff --git a/src/Core/Auth/AuthService.php b/src/Core/Auth/AuthService.php index f9a72a68..10884254 100644 --- a/src/Core/Auth/AuthService.php +++ b/src/Core/Auth/AuthService.php @@ -31,7 +31,7 @@ class AuthService { * @param array $rData Submitted form data (includes `edit` id when updating). * @return array ['status' => STATUS_* constant, 'data' => payload]. */ - public static function processCode($rData) { + public static function processCode(array $rData) { global $db; if (isset($rData['edit'])) { $rArray = AdminHelpers::overwriteData(AuthRepository::getCodeById($rData['edit']), $rData); @@ -49,7 +49,7 @@ class AuthService { } if (isset($rData['groups'])) { - $rArray['groups'] = array(); + $rArray['groups'] = []; foreach ($rData['groups'] as $rGroupID) { $rArray['groups'][] = intval($rGroupID); } @@ -57,7 +57,7 @@ class AuthService { $rArray['groups'] = is_string($rArray['groups'] ?? null) ? (json_decode($rArray['groups'], true) ?: []) : []; } - if (in_array($rData['type'], array(0, 1, 3, 4))) { + if (in_array($rData['type'], [0, 1, 3, 4])) { $rArray['groups'] = '[' . implode(',', array_map('intval', $rArray['groups'])) . ']'; } else { $rArray['groups'] = '[]'; @@ -67,20 +67,20 @@ class AuthService { $rArray['whitelist'] = '[]'; } - if (in_array((int)$rData['type'], [6, 7], true)) { + if (in_array((int) $rData['type'], [6, 7], true)) { if (strlen($rData['code']) < 3) { - return array('status' => STATUS_CODE_LENGTH, 'data' => $rData); + return ['status' => STATUS_CODE_LENGTH, 'data' => $rData]; } } elseif ($rData['type'] != 2 && strlen($rData['code']) < 8) { - return array('status' => STATUS_CODE_LENGTH, 'data' => $rData); + return ['status' => STATUS_CODE_LENGTH, 'data' => $rData]; } if ($rData['type'] == 2 && empty($rData['code'])) { - return array('status' => STATUS_INVALID_CODE, 'data' => $rData); + return ['status' => STATUS_INVALID_CODE, 'data' => $rData]; } - if (in_array($rData['code'], array('admin', 'stream', 'images', 'player_api', 'player', 'playlist', 'epg', 'live', 'movie', 'series', 'status', 'nginx_status', 'get', 'panel_api', 'xmltv', 'probe', 'thumb', 'timeshift', 'auth', 'vauth', 'tsauth', 'hls', 'play', 'key', 'api', 'c'))) { - return array('status' => STATUS_RESERVED_CODE, 'data' => $rData); + if (in_array($rData['code'], ['admin', 'stream', 'images', 'player_api', 'player', 'playlist', 'epg', 'live', 'movie', 'series', 'status', 'nginx_status', 'get', 'panel_api', 'xmltv', 'probe', 'thumb', 'timeshift', 'auth', 'vauth', 'tsauth', 'hls', 'play', 'key', 'api', 'c'])) { + return ['status' => STATUS_RESERVED_CODE, 'data' => $rData]; } if (isset($rData['edit'])) { @@ -90,7 +90,7 @@ class AuthService { } if (0 < $db->num_rows()) { - return array('status' => STATUS_EXISTS_CODE, 'data' => $rData); + return ['status' => STATUS_EXISTS_CODE, 'data' => $rData]; } $rPrepare = QueryHelper::prepareArray($rArray); @@ -99,10 +99,10 @@ class AuthService { if ($db->query($rQuery, ...$rPrepare['data'])) { $rInsertID = $db->last_insert_id(); AuthRepository::updateCodes(); - return array('status' => STATUS_SUCCESS, 'data' => array('insert_id' => $rInsertID, 'orig_code' => $rOrigCode, 'new_code' => $rData['code'])); + return ['status' => STATUS_SUCCESS, 'data' => ['insert_id' => $rInsertID, 'orig_code' => $rOrigCode, 'new_code' => $rData['code']]]; } - return array('status' => STATUS_FAILURE, 'data' => $rData); + return ['status' => STATUS_FAILURE, 'data' => $rData]; } // ────────────────────────────────────────────── @@ -118,7 +118,7 @@ class AuthService { * @param array $rData Submitted form data (includes `edit` id when updating). * @return array ['status' => STATUS_* constant, 'data' => payload or insert_id]. */ - public static function processHMAC($rData) { + public static function processHMAC(array $rData) { global $db, $rSettings; if (isset($rData['edit'])) { $rArray = AdminHelpers::overwriteData(AuthRepository::getHMACById($rData['edit']), $rData); @@ -134,24 +134,24 @@ class AuthService { } if ($rData['keygen'] != 'HMAC KEY HIDDEN' && strlen($rData['keygen']) != 32) { - return array('status' => STATUS_NO_KEY, 'data' => $rData); + return ['status' => STATUS_NO_KEY, 'data' => $rData]; } if (strlen($rData['notes']) == 0) { - return array('status' => STATUS_NO_DESCRIPTION, 'data' => $rData); + return ['status' => STATUS_NO_DESCRIPTION, 'data' => $rData]; } if (isset($rData['edit'])) { if ($rData['keygen'] != 'HMAC KEY HIDDEN') { $db->query('SELECT `id` FROM `hmac_keys` WHERE `key` = ? AND `id` <> ?;', Encryption::encrypt($rData['keygen'], $rSettings['live_streaming_pass'], OPENSSL_EXTRA), $rData['edit']); if (0 < $db->num_rows()) { - return array('status' => STATUS_EXISTS_HMAC, 'data' => $rData); + return ['status' => STATUS_EXISTS_HMAC, 'data' => $rData]; } } } else { $db->query('SELECT `id` FROM `hmac_keys` WHERE `key` = ?;', Encryption::encrypt($rData['keygen'], $rSettings['live_streaming_pass'], OPENSSL_EXTRA)); if (0 < $db->num_rows()) { - return array('status' => STATUS_EXISTS_HMAC, 'data' => $rData); + return ['status' => STATUS_EXISTS_HMAC, 'data' => $rData]; } } @@ -164,10 +164,10 @@ class AuthService { if ($db->query($rQuery, ...$rPrepare['data'])) { $rInsertID = $db->last_insert_id(); - return array('status' => STATUS_SUCCESS, 'data' => array('insert_id' => $rInsertID)); + return ['status' => STATUS_SUCCESS, 'data' => ['insert_id' => $rInsertID]]; } - return array('status' => STATUS_FAILURE, 'data' => $rData); + return ['status' => STATUS_FAILURE, 'data' => $rData]; } /** @@ -184,7 +184,7 @@ class AuthService { * query string can make: null, an array). * @return bool */ - public static function secretMatches($rKnown, $rGiven): bool { + public static function secretMatches(mixed $rKnown, mixed $rGiven): bool { if (!is_scalar($rKnown) || !is_string($rGiven)) { return false; } @@ -212,7 +212,7 @@ class AuthService { * @param int $rMaxConnections Max-connections component. * @return int|null Matching HMAC key id, or null if no key matches. */ - public static function validateHMAC($rHMAC, $rExpiry, $rStreamID, $rExtension, $rIP = '', $rMACIP = '', $rIdentifier = '', $rMaxConnections = 0) { + public static function validateHMAC(string $rHMAC, int|string $rExpiry, int|string $rStreamID, string $rExtension, string $rIP = '', string $rMACIP = '', string $rIdentifier = '', int $rMaxConnections = 0) { global $db, $rSettings; $rCached = $rSettings['enable_cache']; if (0 < strlen($rIP) && 0 < strlen($rMACIP) && $rIP != $rMACIP) { @@ -223,7 +223,7 @@ class AuthService { if ($rCached) { $rKeys = igbinary_unserialize(file_get_contents(CACHE_TMP_PATH . 'hmac_keys')); } else { - $rKeys = array(); + $rKeys = []; $db->query('SELECT `id`, `key` FROM `hmac_keys` WHERE `enabled` = 1;'); foreach ($db->get_rows() as $rKey) { $rKeys[] = $rKey; diff --git a/src/Core/Auth/Authenticator.php b/src/Core/Auth/Authenticator.php index 9370d4f9..24b67833 100644 --- a/src/Core/Auth/Authenticator.php +++ b/src/Core/Auth/Authenticator.php @@ -1,6 +1,5 @@ $rSecret, 'response' => $rToken)); + $rPost = http_build_query(['secret' => $rSecret, 'response' => $rToken]); $rUrl = 'https://www.google.com/recaptcha/api/siteverify'; $rRaw = false; $rErr = ''; @@ -93,7 +92,7 @@ class Authenticator { global $db, $rSettings; if (!empty($rSettings['recaptcha_enable']) && !$rBypassRecaptcha) { if (!self::verifyRecaptcha($rData['g-recaptcha-response'] ?? '')) { - return array('status' => STATUS_INVALID_CAPTCHA); + return ['status' => STATUS_INVALID_CAPTCHA]; } } @@ -105,7 +104,7 @@ class Authenticator { // Always recorded, whatever save_login_logs says: these rows are what // the login flood limit counts (loginFloodExceeded). $db->query("INSERT INTO `login_logs`(`type`, `access_code`, `user_id`, `status`, `login_ip`, `date`) VALUES('ADMIN', ?, 0, ?, ?, ?);", $rAccessCode['id'] ?? null, 'INVALID_LOGIN', $rIP, time()); - return array('status' => STATUS_FAILURE); + return ['status' => STATUS_FAILURE]; } $db->query('SELECT COUNT(*) AS `count` FROM `access_codes`;'); @@ -119,7 +118,7 @@ class Authenticator { if (!empty($rSettings['save_login_logs'])) { $db->query("INSERT INTO `login_logs`(`type`, `access_code`, `user_id`, `status`, `login_ip`, `date`) VALUES('ADMIN', ?, ?, ?, ?, ?);", $rAccessCode['id'], $rUserInfo['id'], 'INVALID_CODE', $rIP, time()); } - return array('status' => STATUS_INVALID_CODE); + return ['status' => STATUS_INVALID_CODE]; } $rPermissions = AuthRepository::getPermissions($rUserInfo['member_group_id']); @@ -127,7 +126,7 @@ class Authenticator { if (!empty($rSettings['save_login_logs'])) { $db->query("INSERT INTO `login_logs`(`type`, `access_code`, `user_id`, `status`, `login_ip`, `date`) VALUES('ADMIN', ?, ?, ?, ?, ?);", $rAccessCode['id'], $rUserInfo['id'], 'NOT_ADMIN', $rIP, time()); } - return array('status' => STATUS_NOT_ADMIN); + return ['status' => STATUS_NOT_ADMIN]; } if ($rUserInfo['status'] == 1) { @@ -143,17 +142,17 @@ class Authenticator { if (!empty($rSettings['save_login_logs'])) { $db->query("INSERT INTO `login_logs`(`type`, `access_code`, `user_id`, `status`, `login_ip`, `date`) VALUES('ADMIN', ?, ?, ?, ?, ?);", $rAccessCode['id'], $rUserInfo['id'], 'SUCCESS', $rIP, time()); } - return array('status' => STATUS_SUCCESS); + return ['status' => STATUS_SUCCESS]; } if (!$rUserInfo['status']) { if (!empty($rSettings['save_login_logs'])) { $db->query("INSERT INTO `login_logs`(`type`, `access_code`, `user_id`, `status`, `login_ip`, `date`) VALUES('ADMIN', ?, ?, ?, ?, ?);", $rAccessCode['id'], $rUserInfo['id'], 'DISABLED', $rIP, time()); } - return array('status' => STATUS_DISABLED); + return ['status' => STATUS_DISABLED]; } - return array('status' => STATUS_FAILURE); + return ['status' => STATUS_FAILURE]; } /** @@ -166,7 +165,7 @@ class Authenticator { global $db, $rSettings; if (!empty($rSettings['recaptcha_enable'])) { if (!self::verifyRecaptcha($rData['g-recaptcha-response'] ?? '')) { - return array('status' => STATUS_INVALID_CAPTCHA); + return ['status' => STATUS_INVALID_CAPTCHA]; } } @@ -177,14 +176,14 @@ class Authenticator { if (!isset($rUserInfo)) { // Always recorded: the login flood limit counts these (see login()). $db->query("INSERT INTO `login_logs`(`type`, `access_code`, `user_id`, `status`, `login_ip`, `date`) VALUES('RESELLER', ?, 0, ?, ?, ?);", $rAccessCode['id'] ?? null, 'INVALID_LOGIN', $rIP, time()); - return array('status' => STATUS_FAILURE); + return ['status' => STATUS_FAILURE]; } if (!(in_array($rUserInfo['member_group_id'], ($rAccessCode && isset($rAccessCode['groups'])) ? (json_decode($rAccessCode['groups'], true) ?: []) : []) || count(AuthRepository::getActiveCodes(MAIN_HOME)) == 0)) { if (!empty($rSettings['save_login_logs'])) { $db->query("INSERT INTO `login_logs`(`type`, `access_code`, `user_id`, `status`, `login_ip`, `date`) VALUES('RESELLER', ?, ?, ?, ?, ?);", $rAccessCode['id'], $rUserInfo['id'], 'INVALID_CODE', $rIP, time()); } - return array('status' => STATUS_INVALID_CODE); + return ['status' => STATUS_INVALID_CODE]; } $rPermissions = AuthRepository::getPermissions($rUserInfo['member_group_id']); @@ -192,7 +191,7 @@ class Authenticator { if (!empty($rSettings['save_login_logs'])) { $db->query("INSERT INTO `login_logs`(`type`, `access_code`, `user_id`, `status`, `login_ip`, `date`) VALUES('RESELLER', ?, ?, ?, ?, ?);", $rAccessCode['id'], $rUserInfo['id'], 'NOT_ADMIN', $rIP, time()); } - return array('status' => STATUS_NOT_RESELLER); + return ['status' => STATUS_NOT_RESELLER]; } if ($rUserInfo['status'] == 1) { @@ -208,17 +207,17 @@ class Authenticator { if (!empty($rSettings['save_login_logs'])) { $db->query("INSERT INTO `login_logs`(`type`, `access_code`, `user_id`, `status`, `login_ip`, `date`) VALUES('RESELLER', ?, ?, ?, ?, ?);", $rAccessCode['id'], $rUserInfo['id'], 'SUCCESS', $rIP, time()); } - return array('status' => STATUS_SUCCESS); + return ['status' => STATUS_SUCCESS]; } if (!$rUserInfo['status']) { if (!empty($rSettings['save_login_logs'])) { $db->query("INSERT INTO `login_logs`(`type`, `access_code`, `user_id`, `status`, `login_ip`, `date`) VALUES('RESELLER', ?, ?, ?, ?, ?);", $rAccessCode['id'], $rUserInfo['id'], 'DISABLED', $rIP, time()); } - return array('status' => STATUS_DISABLED); + return ['status' => STATUS_DISABLED]; } - return array('status' => STATUS_FAILURE); + return ['status' => STATUS_FAILURE]; } /** diff --git a/src/Core/Auth/Authorization.php b/src/Core/Auth/Authorization.php index 40902551..88f71acd 100644 --- a/src/Core/Auth/Authorization.php +++ b/src/Core/Auth/Authorization.php @@ -1,6 +1,5 @@ query('SELECT `id` FROM `users` WHERE `id` = ? AND (`owner_id` IN (' . implode(',', $rReports) . ') OR `id` = ?);', $rID, $rUserInfo['id']); @@ -59,7 +58,7 @@ class Authorization { } if ($rType == 'line') { - $rReports = array_map('intval', array_merge(array($rUserInfo['id']), $rPermissions['all_reports'])); + $rReports = array_map('intval', array_merge([$rUserInfo['id']], $rPermissions['all_reports'])); if (0 < count($rReports)) { $db->query('SELECT `id` FROM `lines` WHERE `id` = ? AND `member_id` IN (' . implode(',', $rReports) . ');', $rID); return 0 < $db->num_rows(); @@ -72,7 +71,7 @@ class Authorization { } if (0 < count($rPermissions['advanced']) && $rUserInfo['member_group_id'] != 1) { - return in_array($rID, ($rPermissions['advanced'] ?: array())); + return in_array($rID, ($rPermissions['advanced'] ?: [])); } return true; diff --git a/src/Core/Auth/BruteforceGuard.php b/src/Core/Auth/BruteforceGuard.php index 9069bd50..344dfa2b 100644 --- a/src/Core/Auth/BruteforceGuard.php +++ b/src/Core/Auth/BruteforceGuard.php @@ -1,6 +1,5 @@ query('INSERT INTO `blocked_ips` (`ip`,`notes`,`date`) VALUES(?,?,?)', $ip, $reason, time()); - } - // Force-refresh blocked IPs cache - if (class_exists(BlocklistService::class, false)) { - BlocklistService::getBlockedIPs(true); - } - } - touch(FLOOD_TMP_PATH . 'block_' . $ip); - } + /** + * Block an IP: insert into DB (or signal if in cached/streaming mode). + * + * @param bool $useCachedMode Use signal-based blocking + */ + private static function blockIP(string $ip, string $reason, bool $useCachedMode = false): void { + if ($useCachedMode && !empty($GLOBALS['rCached'])) { + $signalKey = (stripos($reason, 'BRUTEFORCE') !== false ? 'bruteforce_attack' : 'flood_attack'); + SignalQueue::push($signalKey . '/' . $ip, 1); + } else { + $db = self::getDB(); + if ($db) { + $db->query('INSERT INTO `blocked_ips` (`ip`,`notes`,`date`) VALUES(?,?,?)', $ip, $reason, time()); + } + // Force-refresh blocked IPs cache + if (class_exists(BlocklistService::class, false)) { + BlocklistService::getBlockedIPs(true); + } + } + touch(FLOOD_TMP_PATH . 'block_' . $ip); + } - /** - * Check for flood attacks (too many requests per time window). - * - * @param string|null $ip IP address (auto-detected if null) - * @param bool $useCachedMode Use signal-based blocking for streaming context - */ - public static function checkFlood(?string $ip = null, bool $useCachedMode = false): void { - $settings = self::getSettings(); - if (empty($settings['flood_limit']) || $settings['flood_limit'] == 0) { - return; - } + /** + * Check for flood attacks (too many requests per time window). + * + * @param string|null $ip IP address (auto-detected if null) + * @param bool $useCachedMode Use signal-based blocking for streaming context + */ + public static function checkFlood(?string $ip = null, bool $useCachedMode = false): void { + $settings = self::getSettings(); + if (empty($settings['flood_limit']) || $settings['flood_limit'] == 0) { + return; + } - if (!$ip) { - $ip = self::getUserIP(); - } + if (!$ip) { + $ip = self::getUserIP(); + } - $allowedIPs = self::getAllowedIPs(); - if (empty($ip) || in_array($ip, $allowedIPs)) { - return; - } + $allowedIPs = self::getAllowedIPs(); + if (empty($ip) || in_array($ip, $allowedIPs)) { + return; + } - $floodExclude = array_filter(array_unique(explode(',', $settings['flood_ips_exclude'] ?? ''))); - if (in_array($ip, $floodExclude)) { - return; - } + $floodExclude = array_filter(array_unique(explode(',', $settings['flood_ips_exclude'] ?? ''))); + if (in_array($ip, $floodExclude)) { + return; + } - $ipFile = FLOOD_TMP_PATH . $ip; - if (file_exists($ipFile)) { - $floodRow = json_decode(file_get_contents($ipFile), true); - $floodSeconds = $settings['flood_seconds']; - $floodLimit = $settings['flood_limit']; + $ipFile = FLOOD_TMP_PATH . $ip; + if (file_exists($ipFile)) { + $floodRow = json_decode(file_get_contents($ipFile), true); + $floodSeconds = $settings['flood_seconds']; + $floodLimit = $settings['flood_limit']; - if (time() - $floodRow['last_request'] <= $floodSeconds) { - $floodRow['requests']++; - if ($floodLimit > $floodRow['requests']) { - $floodRow['last_request'] = time(); - file_put_contents($ipFile, json_encode($floodRow), LOCK_EX); - } else { - $blockedIPs = self::getBlockedIPs(); - if (!in_array($ip, $blockedIPs)) { - self::blockIP($ip, 'FLOOD ATTACK', $useCachedMode); - } else { - touch(FLOOD_TMP_PATH . 'block_' . $ip); - } - unlink($ipFile); - return; - } - } else { - $floodRow['requests'] = 0; - $floodRow['last_request'] = time(); - file_put_contents($ipFile, json_encode($floodRow), LOCK_EX); - } - } else { - file_put_contents($ipFile, json_encode(array('requests' => 0, 'last_request' => time())), LOCK_EX); - } - } + if (time() - $floodRow['last_request'] <= $floodSeconds) { + $floodRow['requests']++; + if ($floodLimit > $floodRow['requests']) { + $floodRow['last_request'] = time(); + file_put_contents($ipFile, json_encode($floodRow), LOCK_EX); + } else { + $blockedIPs = self::getBlockedIPs(); + if (!in_array($ip, $blockedIPs)) { + self::blockIP($ip, 'FLOOD ATTACK', $useCachedMode); + } else { + touch(FLOOD_TMP_PATH . 'block_' . $ip); + } + unlink($ipFile); + return; + } + } else { + $floodRow['requests'] = 0; + $floodRow['last_request'] = time(); + file_put_contents($ipFile, json_encode($floodRow), LOCK_EX); + } + } else { + file_put_contents($ipFile, json_encode(['requests' => 0, 'last_request' => time()]), LOCK_EX); + } + } - /** - * Check for brute-force attacks (too many unique MACs/usernames). - * - * @param string|null $ip IP address (auto-detected if null) - * @param string|null $mac MAC address - * @param string|null $username Username - * @param bool $useCachedMode Use signal-based blocking for streaming context - */ - public static function checkBruteforce(?string $ip = null, ?string $mac = null, ?string $username = null, bool $useCachedMode = false): void { - if (!$mac && !$username) { - return; - } + /** + * Check for brute-force attacks (too many unique MACs/usernames). + * + * @param string|null $ip IP address (auto-detected if null) + * @param string|null $mac MAC address + * @param string|null $username Username + * @param bool $useCachedMode Use signal-based blocking for streaming context + */ + public static function checkBruteforce(?string $ip = null, ?string $mac = null, ?string $username = null, bool $useCachedMode = false): void { + if (!$mac && !$username) { + return; + } - $settings = self::getSettings(); + $settings = self::getSettings(); - if ($mac && $settings['bruteforce_mac_attempts'] == 0) { - return; - } - if ($username && $settings['bruteforce_username_attempts'] == 0) { - return; - } + if ($mac && $settings['bruteforce_mac_attempts'] == 0) { + return; + } + if ($username && $settings['bruteforce_username_attempts'] == 0) { + return; + } - if (!$ip) { - $ip = self::getUserIP(); - } + if (!$ip) { + $ip = self::getUserIP(); + } - $allowedIPs = self::getAllowedIPs(); - if (empty($ip) || in_array($ip, $allowedIPs)) { - return; - } + $allowedIPs = self::getAllowedIPs(); + if (empty($ip) || in_array($ip, $allowedIPs)) { + return; + } - $floodExclude = array_filter(array_unique(explode(',', $settings['flood_ips_exclude'] ?? ''))); - if (in_array($ip, $floodExclude)) { - return; - } + $floodExclude = array_filter(array_unique(explode(',', $settings['flood_ips_exclude'] ?? ''))); + if (in_array($ip, $floodExclude)) { + return; + } - $floodType = (!is_null($mac) ? 'mac' : 'user'); - $term = (!is_null($mac) ? $mac : $username); - $ipFile = FLOOD_TMP_PATH . $ip . '_' . $floodType; + $floodType = (!is_null($mac) ? 'mac' : 'user'); + $term = (!is_null($mac) ? $mac : $username); + $ipFile = FLOOD_TMP_PATH . $ip . '_' . $floodType; - if (file_exists($ipFile)) { - $floodRow = json_decode(file_get_contents($ipFile), true); - $floodSeconds = intval($settings['bruteforce_frequency']); - $floodLimit = intval($settings[array('mac' => 'bruteforce_mac_attempts', 'user' => 'bruteforce_username_attempts')[$floodType]]); - $floodRow['attempts'] = self::truncateAttempts($floodRow['attempts'], $floodSeconds); + if (file_exists($ipFile)) { + $floodRow = json_decode(file_get_contents($ipFile), true); + $floodSeconds = intval($settings['bruteforce_frequency']); + $floodLimit = intval($settings[['mac' => 'bruteforce_mac_attempts', 'user' => 'bruteforce_username_attempts'][$floodType]]); + $floodRow['attempts'] = self::truncateAttempts($floodRow['attempts'], $floodSeconds); - if (!in_array($term, array_keys($floodRow['attempts']))) { - $floodRow['attempts'][$term] = time(); - if ($floodLimit > count($floodRow['attempts'])) { - file_put_contents($ipFile, json_encode($floodRow), LOCK_EX); - } else { - $blockedIPs = self::getBlockedIPs(); - if (!in_array($ip, $blockedIPs)) { - self::blockIP($ip, 'BRUTEFORCE ' . strtoupper($floodType) . ' ATTACK', $useCachedMode); - } else { - touch(FLOOD_TMP_PATH . 'block_' . $ip); - } - unlink($ipFile); - return; - } - } - } else { - $floodRow = array('attempts' => array($term => time())); - file_put_contents($ipFile, json_encode($floodRow), LOCK_EX); - } - } + if (!in_array($term, array_keys($floodRow['attempts']))) { + $floodRow['attempts'][$term] = time(); + if ($floodLimit > count($floodRow['attempts'])) { + file_put_contents($ipFile, json_encode($floodRow), LOCK_EX); + } else { + $blockedIPs = self::getBlockedIPs(); + if (!in_array($ip, $blockedIPs)) { + self::blockIP($ip, 'BRUTEFORCE ' . strtoupper($floodType) . ' ATTACK', $useCachedMode); + } else { + touch(FLOOD_TMP_PATH . 'block_' . $ip); + } + unlink($ipFile); + return; + } + } + } else { + $floodRow = ['attempts' => [$term => time()]]; + file_put_contents($ipFile, json_encode($floodRow), LOCK_EX); + } + } - /** - * Check for auth flood (too many auth requests from same user+IP). - * - * @param array $user User info array (must have 'id' and 'is_restreamer') - * @param string|null $ip IP address (auto-detected if null) - */ - public static function checkAuthFlood(array $user, ?string $ip = null): void { - $settings = self::getSettings(); - if (empty($settings['auth_flood_limit']) || $settings['auth_flood_limit'] == 0) { - return; - } + /** + * Check for auth flood (too many auth requests from same user+IP). + * + * @param array $user User info array (must have 'id' and 'is_restreamer') + * @param string|null $ip IP address (auto-detected if null) + */ + public static function checkAuthFlood(array $user, ?string $ip = null): void { + $settings = self::getSettings(); + if (empty($settings['auth_flood_limit']) || $settings['auth_flood_limit'] == 0) { + return; + } - if (!empty($user['is_restreamer'])) { - return; - } + if (!empty($user['is_restreamer'])) { + return; + } - if (!$ip) { - $ip = self::getUserIP(); - } + if (!$ip) { + $ip = self::getUserIP(); + } - $allowedIPs = self::getAllowedIPs(); - if (empty($ip) || in_array($ip, $allowedIPs)) { - return; - } + $allowedIPs = self::getAllowedIPs(); + if (empty($ip) || in_array($ip, $allowedIPs)) { + return; + } - $floodExclude = array_filter(array_unique(explode(',', $settings['flood_ips_exclude'] ?? ''))); - if (in_array($ip, $floodExclude)) { - return; - } + $floodExclude = array_filter(array_unique(explode(',', $settings['flood_ips_exclude'] ?? ''))); + if (in_array($ip, $floodExclude)) { + return; + } - $userFile = FLOOD_TMP_PATH . intval($user['id']) . '_' . $ip; - if (file_exists($userFile)) { - $floodRow = json_decode(file_get_contents($userFile), true); + $userFile = FLOOD_TMP_PATH . intval($user['id']) . '_' . $ip; + if (file_exists($userFile)) { + $floodRow = json_decode(file_get_contents($userFile), true); - if (isset($floodRow['block_until']) && time() < $floodRow['block_until']) { - sleep(intval($settings['auth_flood_sleep'])); - } + if (isset($floodRow['block_until']) && time() < $floodRow['block_until']) { + sleep(intval($settings['auth_flood_sleep'])); + } - $floodSeconds = intval($settings['auth_flood_seconds']); - $floodLimit = intval($settings['auth_flood_limit']); - $floodRow['attempts'] = self::truncateAttempts($floodRow['attempts'], $floodSeconds, true); + $floodSeconds = intval($settings['auth_flood_seconds']); + $floodLimit = intval($settings['auth_flood_limit']); + $floodRow['attempts'] = self::truncateAttempts($floodRow['attempts'], $floodSeconds, true); - if (!($floodLimit > count($floodRow['attempts']))) { - $floodRow['block_until'] = time() + intval($settings['auth_flood_seconds']); - } + if (!($floodLimit > count($floodRow['attempts']))) { + $floodRow['block_until'] = time() + intval($settings['auth_flood_seconds']); + } - $floodRow['attempts'][] = time(); - file_put_contents($userFile, json_encode($floodRow), LOCK_EX); - } else { - file_put_contents($userFile, json_encode(array('attempts' => array(time()))), LOCK_EX); - } - } + $floodRow['attempts'][] = time(); + file_put_contents($userFile, json_encode($floodRow), LOCK_EX); + } else { + file_put_contents($userFile, json_encode(['attempts' => [time()]]), LOCK_EX); + } + } - /** - * Filter out expired attempts from the list. - * - * @param array $attempts Array of attempts (keyed or indexed by time) - * @param int $frequency Time window in seconds - * @param bool $list If true, treat as indexed array; otherwise as associative - * @return array Filtered attempts - */ - public static function truncateAttempts(array $attempts, int $frequency, bool $list = false): array { - $allowed = array(); - $now = time(); + /** + * Filter out expired attempts from the list. + * + * @param array $attempts Array of attempts (keyed or indexed by time) + * @param int $frequency Time window in seconds + * @param bool $list If true, treat as indexed array; otherwise as associative + * @return array Filtered attempts + */ + public static function truncateAttempts(array $attempts, int $frequency, bool $list = false): array { + $allowed = []; + $now = time(); - if ($list) { - foreach ($attempts as $attemptTime) { - if ($now - $attemptTime <= $frequency) { - $allowed[] = $attemptTime; - } - } - } else { - foreach ($attempts as $attempt => $attemptTime) { - if ($now - $attemptTime <= $frequency) { - $allowed[$attempt] = $attemptTime; - } - } - } + if ($list) { + foreach ($attempts as $attemptTime) { + if ($now - $attemptTime <= $frequency) { + $allowed[] = $attemptTime; + } + } + } else { + foreach ($attempts as $attempt => $attemptTime) { + if ($now - $attemptTime <= $frequency) { + $allowed[$attempt] = $attemptTime; + } + } + } - return $allowed; - } + return $allowed; + } } diff --git a/src/Core/Auth/PageAuthorization.php b/src/Core/Auth/PageAuthorization.php index a84a6bef..d0613504 100644 --- a/src/Core/Auth/PageAuthorization.php +++ b/src/Core/Auth/PageAuthorization.php @@ -1,6 +1,5 @@ > */ - protected static array $keyMap = [ - 'admin' => [ - 'auth' => 'hash', - 'activity' => 'last_activity', - 'ip' => 'ip', - 'code' => 'code', - 'verify' => 'verify', - ], - 'reseller' => [ - 'auth' => 'reseller', - 'activity' => 'rlast_activity', - 'ip' => 'rip', - 'code' => 'rcode', - 'verify' => 'rverify', - ], - 'player' => [ - 'auth' => 'phash', - 'verify' => 'pverify', - ], - ]; + protected static bool $started = false; - /** - * Start a session for the given context - * - * @param string $context 'admin' or 'reseller' - * @param int $timeout Timeout in minutes (default: 60) - */ - public static function start(string $context, int $timeout = self::DEFAULT_TIMEOUT): void { - self::$context = $context; - self::$timeout = $timeout; + /** @var array> */ + protected static array $keyMap = [ + 'admin' => [ + 'auth' => 'hash', + 'activity' => 'last_activity', + 'ip' => 'ip', + 'code' => 'code', + 'verify' => 'verify', + ], + 'reseller' => [ + 'auth' => 'reseller', + 'activity' => 'rlast_activity', + 'ip' => 'rip', + 'code' => 'rcode', + 'verify' => 'rverify', + ], + 'player' => [ + 'auth' => 'phash', + 'verify' => 'pverify', + ], + ]; - if (session_status() === PHP_SESSION_NONE) { - session_start(); - } + /** + * Start a session for the given context + * + * @param string $context 'admin' or 'reseller' + * @param int $timeout Timeout in minutes (default: 60) + */ + public static function start(string $context, int $timeout = self::DEFAULT_TIMEOUT): void { + self::$context = $context; + self::$timeout = $timeout; - self::$started = true; - self::checkTimeout(); - } + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } - /** - * Require authentication — redirect to login if not authenticated - * - * If called as an AJAX endpoint (script_filename == session.php), - * returns JSON result instead of redirecting. - * - * @param string|null $loginUrl Override login redirect URL - */ - public static function requireAuth(?string $loginUrl = null): void { - $authKey = self::getKey('auth'); + self::$started = true; + self::checkTimeout(); + } - // Direct access to session.php endpoint — return JSON status - if (basename($_SERVER['SCRIPT_FILENAME']) === 'session.php') { - $isAuth = isset($_SESSION[$authKey]); - echo json_encode(['result' => $isAuth]); - exit; - } + /** + * Require authentication — redirect to login if not authenticated + * + * If called as an AJAX endpoint (script_filename == session.php), + * returns JSON result instead of redirecting. + * + * @param string|null $loginUrl Override login redirect URL + */ + public static function requireAuth(?string $loginUrl = null): void { + $authKey = self::getKey('auth'); - // Not authenticated — redirect to login - if (!isset($_SESSION[$authKey])) { - if ($loginUrl === null) { - $prefix = (self::$context === 'reseller') ? '' : './'; - $loginUrl = $prefix . 'login?referrer=' . urlencode(basename($_SERVER['REQUEST_URI'], '.php')); - } + // Direct access to session.php endpoint — return JSON status + if (basename($_SERVER['SCRIPT_FILENAME']) === 'session.php') { + $isAuth = isset($_SESSION[$authKey]); + echo json_encode(['result' => $isAuth]); + exit; + } - header('Location: ' . $loginUrl); - exit; - } + // Not authenticated — redirect to login + if (!isset($_SESSION[$authKey])) { + if ($loginUrl === null) { + $prefix = (self::$context === 'reseller') ? '' : './'; + $loginUrl = $prefix . 'login?referrer=' . urlencode(basename($_SERVER['REQUEST_URI'], '.php')); + } - // Authenticated — update activity timestamp and close session - self::touch(); - } + header('Location: ' . $loginUrl); + exit; + } - /** - * Check if user is authenticated (non-blocking, no redirect) - * - * @return bool - */ - public static function isAuthenticated(): bool { - if (!self::$started) { - return false; - } + // Authenticated — update activity timestamp and close session + self::touch(); + } - $authKey = self::getKey('auth'); - return isset($_SESSION[$authKey]); - } + /** + * Check if user is authenticated (non-blocking, no redirect) + * + * @return bool + */ + public static function isAuthenticated(): bool { + if (!self::$started) { + return false; + } - /** - * Get the auth token/hash for current session - * - * @return mixed - */ - public static function getUser(): mixed { - $authKey = self::getKey('auth'); - return isset($_SESSION[$authKey]) ? $_SESSION[$authKey] : null; - } + $authKey = self::getKey('auth'); + return isset($_SESSION[$authKey]); + } - /** - * Get a session value by logical name - * - * @param string $name Logical name: 'auth', 'activity', 'ip', 'code', 'verify' - * @return mixed - */ - public static function getValue(string $name): mixed { - $key = self::getKey($name); - return isset($_SESSION[$key]) ? $_SESSION[$key] : null; - } + /** + * Get the auth token/hash for current session + * + * @return mixed + */ + public static function getUser(): mixed { + $authKey = self::getKey('auth'); + return isset($_SESSION[$authKey]) ? $_SESSION[$authKey] : null; + } - /** - * Set a session value by logical name - * - * @param string $name Logical name - * @param mixed $value Value to store - */ - public static function setValue(string $name, mixed $value): void { - $key = self::getKey($name); - $_SESSION[$key] = $value; - } + /** + * Get a session value by logical name + * + * @param string $name Logical name: 'auth', 'activity', 'ip', 'code', 'verify' + * @return mixed + */ + public static function getValue(string $name): mixed { + $key = self::getKey($name); + return isset($_SESSION[$key]) ? $_SESSION[$key] : null; + } - /** - * Create an authenticated session - * - * @param mixed $hash Authentication hash/token - * @param string|null $ip Client IP address - */ - public static function login(mixed $hash, ?string $ip = null): void { - if (session_status() === PHP_SESSION_NONE) { - session_start(); - } + /** + * Set a session value by logical name + * + * @param string $name Logical name + * @param mixed $value Value to store + */ + public static function setValue(string $name, mixed $value): void { + $key = self::getKey($name); + $_SESSION[$key] = $value; + } - self::setValue('auth', $hash); - self::setValue('activity', time()); + /** + * Create an authenticated session + * + * @param mixed $hash Authentication hash/token + * @param string|null $ip Client IP address + */ + public static function login(mixed $hash, ?string $ip = null): void { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } - if ($ip !== null) { - self::setValue('ip', $ip); - } - } + self::setValue('auth', $hash); + self::setValue('activity', time()); - /** - * Destroy the current session (logout) - */ - public static function destroy(): void { - if (!self::$started && session_status() === PHP_SESSION_NONE) { - session_start(); - } + if ($ip !== null) { + self::setValue('ip', $ip); + } + } - // Clear context-specific keys - foreach (self::$keyMap[self::$context] as $key) { - if (isset($_SESSION[$key])) { - unset($_SESSION[$key]); - } - } + /** + * Destroy the current session (logout) + */ + public static function destroy(): void { + if (!self::$started && session_status() === PHP_SESSION_NONE) { + session_start(); + } - // If no other context is active, destroy the whole session - $otherContext = (self::$context === 'admin') ? 'reseller' : 'admin'; - $otherAuthKey = self::$keyMap[$otherContext]['auth']; + // Clear context-specific keys + foreach (self::$keyMap[self::$context] as $key) { + if (isset($_SESSION[$key])) { + unset($_SESSION[$key]); + } + } - if (!isset($_SESSION[$otherAuthKey])) { - session_destroy(); - } + // If no other context is active, destroy the whole session + $otherContext = (self::$context === 'admin') ? 'reseller' : 'admin'; + $otherAuthKey = self::$keyMap[$otherContext]['auth']; - self::$started = false; - } + if (!isset($_SESSION[$otherAuthKey])) { + session_destroy(); + } - /** - * Clear session keys for a specific context without destroying the session. - * Drop-in replacement for legacy destroySession($type). - * - * @param string $context 'admin', 'reseller', or 'player' - */ - public static function clearContext(string $context): void { - if (!isset(self::$keyMap[$context])) { - return; - } - foreach (self::$keyMap[$context] as $key) { - unset($_SESSION[$key]); - } - } + self::$started = false; + } - /** - * Update the last activity timestamp and close session for writing - */ - public static function touch(): void { - $activityKey = self::getKey('activity'); - $_SESSION[$activityKey] = time(); - session_write_close(); - } + /** + * Clear session keys for a specific context without destroying the session. + * Drop-in replacement for legacy destroySession($type). + * + * @param string $context 'admin', 'reseller', or 'player' + */ + public static function clearContext(string $context): void { + if (!isset(self::$keyMap[$context])) { + return; + } + foreach (self::$keyMap[$context] as $key) { + unset($_SESSION[$key]); + } + } - /** - * Get current context - * - * @return string|null - */ - public static function getContext(): ?string { - return self::$context; - } + /** + * Update the last activity timestamp and close session for writing + */ + public static function touch(): void { + $activityKey = self::getKey('activity'); + $_SESSION[$activityKey] = time(); + session_write_close(); + } - // ─────────────────────────────────────────────────────────── - // Internal Methods - // ─────────────────────────────────────────────────────────── + /** + * Get current context + * + * @return string|null + */ + public static function getContext(): ?string { + return self::$context; + } - /** - * Check for session timeout and expire if needed - */ - protected static function checkTimeout(): void { - $authKey = self::getKey('auth'); - $activityKey = self::getKey('activity'); + // ─────────────────────────────────────────────────────────── + // Internal Methods + // ─────────────────────────────────────────────────────────── - if (isset($_SESSION[$authKey]) && isset($_SESSION[$activityKey])) { - $elapsed = time() - $_SESSION[$activityKey]; + /** + * Check for session timeout and expire if needed + */ + protected static function checkTimeout(): void { + $authKey = self::getKey('auth'); + $activityKey = self::getKey('activity'); - if ($elapsed > (self::$timeout * 60)) { - // Session expired — clear all context-specific keys - foreach (self::$keyMap[self::$context] as $key) { - if (isset($_SESSION[$key])) { - unset($_SESSION[$key]); - } - } + if (isset($_SESSION[$authKey]) && isset($_SESSION[$activityKey])) { + $elapsed = time() - $_SESSION[$activityKey]; - // Restart session if it was destroyed - if (session_status() === PHP_SESSION_NONE) { - session_start(); - } - } - } - } + if ($elapsed > (self::$timeout * 60)) { + // Session expired — clear all context-specific keys + foreach (self::$keyMap[self::$context] as $key) { + if (isset($_SESSION[$key])) { + unset($_SESSION[$key]); + } + } - /** - * Get the actual $_SESSION key for a logical name in current context - * - * @param string $name Logical name: 'auth', 'activity', 'ip', 'code', 'verify' - * @return string - */ - protected static function getKey(string $name): string { - if (self::$context === null) { - self::$context = 'admin'; // default fallback - } + // Restart session if it was destroyed + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + } + } + } - if (isset(self::$keyMap[self::$context][$name])) { - return self::$keyMap[self::$context][$name]; - } + /** + * Get the actual $_SESSION key for a logical name in current context + * + * @param string $name Logical name: 'auth', 'activity', 'ip', 'code', 'verify' + * @return string + */ + protected static function getKey(string $name): string { + if (self::$context === null) { + self::$context = 'admin'; // default fallback + } - // Unknown key — return as-is (allows extending) - return $name; - } + if (isset(self::$keyMap[self::$context][$name])) { + return self::$keyMap[self::$context][$name]; + } + + // Unknown key — return as-is (allows extending) + return $name; + } } diff --git a/src/Core/Backup/BackupService.php b/src/Core/Backup/BackupService.php index ab8989b5..c4a58227 100644 --- a/src/Core/Backup/BackupService.php +++ b/src/Core/Backup/BackupService.php @@ -19,7 +19,6 @@ use XcVm\Core\Storage\DropboxClient; */ class BackupService { - private static array $ignoreTables = [ 'detect_restream_logs', 'epg_data', @@ -49,7 +48,7 @@ class BackupService { * * @param string $filename Output SQL file path */ - public static function create($filename) { + public static function create(string $filename) { \XC_VM::db_dump($filename, self::$ignoreTables); } @@ -60,7 +59,7 @@ class BackupService { * @param string $filename SQL file path to restore * @return bool Whether the import succeeded */ - public static function restore($filename) { + public static function restore(string $filename) { if (!\XC_VM::db_restore($filename)) { return false; } @@ -73,7 +72,7 @@ class BackupService { * * @param string $host Remote host IP */ - public static function grantPrivileges($host) { + public static function grantPrivileges(string $host) { \XC_VM::db_grant($host); } @@ -82,7 +81,7 @@ class BackupService { * * @param string $host Remote host IP */ - public static function revokePrivileges($host) { + public static function revokePrivileges(string $host) { \XC_VM::db_revoke($host); } @@ -92,14 +91,14 @@ class BackupService { * @return array[] Each entry: filename, timestamp, date, filesize. */ public static function getLocal() { - $rBackups = array(); + $rBackups = []; foreach (scandir(MAIN_HOME . 'backups/') as $rBackup) { $rInfo = pathinfo(MAIN_HOME . 'backups/' . $rBackup); if ($rInfo['extension'] != 'sql') { } else { - $rBackups[] = array('filename' => $rBackup, 'timestamp' => filemtime(MAIN_HOME . 'backups/' . $rBackup), 'date' => date('Y-m-d H:i:s', filemtime(MAIN_HOME . 'backups/' . $rBackup)), 'filesize' => filesize(MAIN_HOME . 'backups/' . $rBackup)); + $rBackups[] = ['filename' => $rBackup, 'timestamp' => filemtime(MAIN_HOME . 'backups/' . $rBackup), 'date' => date('Y-m-d H:i:s', filemtime(MAIN_HOME . 'backups/' . $rBackup)), 'filesize' => filesize(MAIN_HOME . 'backups/' . $rBackup)]; } } usort( @@ -121,7 +120,7 @@ class BackupService { try { $rClient = new DropboxClient(); - $rClient->SetBearerToken(array('t' => SettingsManager::get('dropbox_token'))); + $rClient->SetBearerToken(['t' => SettingsManager::get('dropbox_token')]); $rClient->GetFiles(); return true; @@ -139,12 +138,12 @@ class BackupService { try { $rClient = new DropboxClient(); - $rClient->SetBearerToken(array('t' => SettingsManager::get('dropbox_token'))); + $rClient->SetBearerToken(['t' => SettingsManager::get('dropbox_token')]); $rFiles = $rClient->GetFiles(); } catch (\exception $e) { - $rFiles = array(); + $rFiles = []; } - $rBackups = array(); + $rBackups = []; foreach ($rFiles as $rFile) { try { @@ -169,11 +168,11 @@ class BackupService { * @param string $rFilename Local destination path. * @return bool True on success. */ - public static function downloadRemote($rPath, $rFilename) { + public static function downloadRemote(string $rPath, string $rFilename) { $rClient = new DropboxClient(); try { - $rClient->SetBearerToken(array('t' => SettingsManager::get('dropbox_token'))); + $rClient->SetBearerToken(['t' => SettingsManager::get('dropbox_token')]); $rClient->downloadFile($rPath, $rFilename); return true; @@ -190,15 +189,15 @@ class BackupService { * @param bool $rOverwrite Overwrite an existing remote file. * @return mixed Upload result, or an object with an 'error' key on failure. */ - public static function uploadRemote($rPath, $rFilename, $rOverwrite = true) { + public static function uploadRemote(string $rPath, string $rFilename, bool $rOverwrite = true) { $rClient = new DropboxClient(); try { - $rClient->SetBearerToken(array('t' => SettingsManager::get('dropbox_token'))); + $rClient->SetBearerToken(['t' => SettingsManager::get('dropbox_token')]); return $rClient->UploadFile($rFilename, $rPath, $rOverwrite); } catch (\exception $e) { - return (object) array('error' => $e); + return (object) ['error' => $e]; } } @@ -208,11 +207,11 @@ class BackupService { * @param string $rPath Remote path to delete. * @return bool True on success. */ - public static function deleteRemote($rPath) { + public static function deleteRemote(string $rPath) { $rClient = new DropboxClient(); try { - $rClient->SetBearerToken(array('t' => SettingsManager::get('dropbox_token'))); + $rClient->SetBearerToken(['t' => SettingsManager::get('dropbox_token')]); $rClient->Delete($rPath); return true; diff --git a/src/Core/Cache/CacheInterface.php b/src/Core/Cache/CacheInterface.php index 717494e3..ce178f02 100644 --- a/src/Core/Cache/CacheInterface.php +++ b/src/Core/Cache/CacheInterface.php @@ -42,47 +42,46 @@ namespace XcVm\Core\Cache; */ interface CacheInterface { + /** + * Retrieve a value from cache + * + * @param string $key Cache key + * @param int|null $maxAge Maximum age in seconds (null = no limit) + * @return mixed|false Cached data or false if not found / expired + */ + public function get(string $key, ?int $maxAge = null); - /** - * Retrieve a value from cache - * - * @param string $key Cache key - * @param int|null $maxAge Maximum age in seconds (null = no limit) - * @return mixed|false Cached data or false if not found / expired - */ - public function get($key, $maxAge = null); + /** + * Store a value in cache + * + * @param string $key Cache key + * @param mixed $data Data to store + * @param int $ttl Time to live in seconds (0 = forever) + * @return bool Success + */ + public function set(string $key, mixed $data, int $ttl = 0); - /** - * Store a value in cache - * - * @param string $key Cache key - * @param mixed $data Data to store - * @param int $ttl Time to live in seconds (0 = forever) - * @return bool Success - */ - public function set($key, $data, $ttl = 0); + /** + * Delete a cache entry + * + * @param string $key Cache key + * @return bool Success + */ + public function delete(string $key); - /** - * Delete a cache entry - * - * @param string $key Cache key - * @return bool Success - */ - public function delete($key); + /** + * Check if a cache key exists (and is not expired) + * + * @param string $key Cache key + * @param int|null $maxAge Maximum age in seconds (null = no limit) + * @return bool + */ + public function has(string $key, ?int $maxAge = null); - /** - * Check if a cache key exists (and is not expired) - * - * @param string $key Cache key - * @param int|null $maxAge Maximum age in seconds (null = no limit) - * @return bool - */ - public function has($key, $maxAge = null); - - /** - * Clear all cache entries - * - * @return bool Success - */ - public function flush(); + /** + * Clear all cache entries + * + * @return bool Success + */ + public function flush(); } diff --git a/src/Core/Cache/FileCache.php b/src/Core/Cache/FileCache.php index f024e5a5..01f5dd38 100644 --- a/src/Core/Cache/FileCache.php +++ b/src/Core/Cache/FileCache.php @@ -34,278 +34,275 @@ namespace XcVm\Core\Cache; */ class FileCache implements CacheInterface { + /** @var string Base directory for cache files */ + protected $basePath; - /** @var string Base directory for cache files */ - protected $basePath; + /** @var bool Whether igbinary extension is available */ + protected $useIgbinary; - /** @var bool Whether igbinary extension is available */ - protected $useIgbinary; + /** + * @param string $basePath Directory for cache files (must end with /) + */ + public function __construct(string $basePath) { + $this->basePath = rtrim($basePath, '/') . '/'; + $this->useIgbinary = function_exists('igbinary_serialize'); - /** - * @param string $basePath Directory for cache files (must end with /) - */ - public function __construct($basePath) { - $this->basePath = rtrim($basePath, '/') . '/'; - $this->useIgbinary = function_exists('igbinary_serialize'); + if (!is_dir($this->basePath)) { + @mkdir($this->basePath, 0755, true); + } + // A root-context boot (installer, `console.php status`) must leave the + // cache dir owned by the panel user, or xc_vm processes cannot create + // cache files in it afterwards. Same pattern as set(). + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + @chown($this->basePath, 'xc_vm'); + @chgrp($this->basePath, 'xc_vm'); + } + } - if (!is_dir($this->basePath)) { - @mkdir($this->basePath, 0755, true); - } - // A root-context boot (installer, `console.php status`) must leave the - // cache dir owned by the panel user, or xc_vm processes cannot create - // cache files in it afterwards. Same pattern as set(). - if (function_exists('posix_geteuid') && posix_geteuid() === 0) { - @chown($this->basePath, 'xc_vm'); - @chgrp($this->basePath, 'xc_vm'); - } - } + /** + * {@inheritdoc} + */ + public function get($key, $maxAge = null) { + $file = $this->basePath . $key; - /** - * {@inheritdoc} - */ - public function get($key, $maxAge = null) { - $file = $this->basePath . $key; + if (!file_exists($file)) { + return false; + } - if (!file_exists($file)) { - return false; - } + // Check TTL based on file modification time + if ($maxAge !== null) { + $age = time() - filemtime($file); + if ($age >= $maxAge) { + return false; + } + } - // Check TTL based on file modification time - if ($maxAge !== null) { - $age = time() - filemtime($file); - if ($age >= $maxAge) { - return false; - } - } + $data = @file_get_contents($file); - $data = @file_get_contents($file); + if ($data === false || $data === '') { + @unlink($file); + return false; + } - if ($data === false || $data === '') { - @unlink($file); - return false; - } + $result = $this->deserialize($data); - $result = $this->deserialize($data); + if ($result === false) { + @unlink($file); + } - if ($result === false) { - @unlink($file); - } + return $result; + } - return $result; - } + /** + * {@inheritdoc} + */ + public function set($key, $data, $ttl = 0) { + $file = $this->basePath . $key; + $serialized = $this->serialize($data); - /** - * {@inheritdoc} - */ - public function set($key, $data, $ttl = 0) { - $file = $this->basePath . $key; - $serialized = $this->serialize($data); + $tmp = $file . '.' . getmypid() . '.tmp'; + if (@file_put_contents($tmp, $serialized, LOCK_EX) === false) { + @unlink($tmp); + $this->warnWriteFailure($file); + return false; + } + if (!@rename($tmp, $file)) { + @unlink($tmp); + $this->warnWriteFailure($file); + return false; + } + // A root-context write (installer, `console.php status`) must stay + // owned by the panel user, or xc_vm daemons cannot refresh the file + // later. Same pattern as Logger. + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + @chown($file, 'xc_vm'); + @chgrp($file, 'xc_vm'); + } + return true; + } - $tmp = $file . '.' . getmypid() . '.tmp'; - if (@file_put_contents($tmp, $serialized, LOCK_EX) === false) { - @unlink($tmp); - $this->warnWriteFailure($file); - return false; - } - if (!@rename($tmp, $file)) { - @unlink($tmp); - $this->warnWriteFailure($file); - return false; - } - // A root-context write (installer, `console.php status`) must stay - // owned by the panel user, or xc_vm daemons cannot refresh the file - // later. Same pattern as Logger. - if (function_exists('posix_geteuid') && posix_geteuid() === 0) { - @chown($file, 'xc_vm'); - @chgrp($file, 'xc_vm'); - } - return true; - } + /** + * Report a failed cache write once per process. + * + * A silently stale cache (bad tmp/ ownership, full or missing tmpfs) keeps + * the panel running on outdated settings with no visible symptom, so the + * first failure must reach the panel log via the error handler. + * + * @param string $file Cache file path that could not be written. + */ + protected function warnWriteFailure(string $file) { + static $warned = false; + if ($warned) { + return; + } + $warned = true; + trigger_error('FileCache: cache write failed for ' . $file . ' — serving stale cache', E_USER_WARNING); + } - /** - * Report a failed cache write once per process. - * - * A silently stale cache (bad tmp/ ownership, full or missing tmpfs) keeps - * the panel running on outdated settings with no visible symptom, so the - * first failure must reach the panel log via the error handler. - * - * @param string $file Cache file path that could not be written. - */ - protected function warnWriteFailure($file) { - static $warned = false; - if ($warned) { - return; - } - $warned = true; - trigger_error('FileCache: cache write failed for ' . $file . ' — serving stale cache', E_USER_WARNING); - } + /** + * {@inheritdoc} + */ + public function delete($key) { + $file = $this->basePath . $key; - /** - * {@inheritdoc} - */ - public function delete($key) { - $file = $this->basePath . $key; + if (file_exists($file)) { + return unlink($file); + } - if (file_exists($file)) { - return unlink($file); - } + return true; + } - return true; - } + /** + * {@inheritdoc} + */ + public function has($key, $maxAge = null) { + $file = $this->basePath . $key; - /** - * {@inheritdoc} - */ - public function has($key, $maxAge = null) { - $file = $this->basePath . $key; + if (!file_exists($file)) { + return false; + } - if (!file_exists($file)) { - return false; - } + if ($maxAge !== null) { + $age = time() - filemtime($file); + if ($age >= $maxAge) { + return false; + } + } - if ($maxAge !== null) { - $age = time() - filemtime($file); - if ($age >= $maxAge) { - return false; - } - } + return true; + } - return true; - } + /** + * {@inheritdoc} + */ + public function flush() { + $files = glob($this->basePath . '*'); - /** - * {@inheritdoc} - */ - public function flush() { - $files = glob($this->basePath . '*'); + if ($files === false) { + return false; + } - if ($files === false) { - return false; - } + foreach ($files as $file) { + if (is_file($file)) { + unlink($file); + } + } - foreach ($files as $file) { - if (is_file($file)) { - unlink($file); - } - } + return true; + } - return true; - } + /** + * Get the file path for a cache key + * + * Useful for direct file operations (e.g., file_exists checks + * in legacy code during migration). + * + * @param string $key Cache key + * @return string Full file path + */ + public function getPath(string $key) { + return $this->basePath . $key; + } - /** - * Get the file path for a cache key - * - * Useful for direct file operations (e.g., file_exists checks - * in legacy code during migration). - * - * @param string $key Cache key - * @return string Full file path - */ - public function getPath($key) { - return $this->basePath . $key; - } + /** + * Get the base directory path + * + * @return string + */ + public function getBasePath() { + return $this->basePath; + } - /** - * Get the base directory path - * - * @return string - */ - public function getBasePath() { - return $this->basePath; - } + /** + * Get modification time of a cache entry + * + * @param string $key Cache key + * @return int|false Unix timestamp or false if not found + */ + public function getAge(string $key) { + $file = $this->basePath . $key; - /** - * Get modification time of a cache entry - * - * @param string $key Cache key - * @return int|false Unix timestamp or false if not found - */ - public function getAge($key) { - $file = $this->basePath . $key; + if (!file_exists($file)) { + return false; + } - if (!file_exists($file)) { - return false; - } + return time() - filemtime($file); + } - return time() - filemtime($file); - } + /** + * Serialize data using igbinary (if available) or PHP serialize + * + * @return string + */ + protected function serialize(mixed $data) { + if ($this->useIgbinary) { + return igbinary_serialize($data); + } - /** - * Serialize data using igbinary (if available) or PHP serialize - * - * @param mixed $data - * @return string - */ - protected function serialize($data) { - if ($this->useIgbinary) { - return igbinary_serialize($data); - } + return serialize($data); + } - return serialize($data); - } + /** + * Deserialize data using igbinary (if available) or PHP unserialize + * + * Returns false on corrupted data (cache miss). + * + * @return mixed|false + */ + protected function deserialize(string $data) { + if ($this->useIgbinary) { + $result = @igbinary_unserialize($data); + if ($result === false && $data !== igbinary_serialize(false)) { + return false; + } + return $result; + } - /** - * Deserialize data using igbinary (if available) or PHP unserialize - * - * Returns false on corrupted data (cache miss). - * - * @param string $data - * @return mixed|false - */ - protected function deserialize($data) { - if ($this->useIgbinary) { - $result = @igbinary_unserialize($data); - if ($result === false && $data !== igbinary_serialize(false)) { - return false; - } - return $result; - } + $result = @unserialize($data); + if ($result === false && $data !== serialize(false)) { + return false; + } + return $result; + } - $result = @unserialize($data); - if ($result === false && $data !== serialize(false)) { - return false; - } - return $result; - } + // ------------------------------------------------------------------ + // Static convenience API (drop-in replacement for CoreUtilities) + // ------------------------------------------------------------------ - // ------------------------------------------------------------------ - // Static convenience API (drop-in replacement for CoreUtilities) - // ------------------------------------------------------------------ + /** @var self|null Singleton instance for static calls */ + private static $defaultInstance; - /** @var self|null Singleton instance for static calls */ - private static $defaultInstance; + /** + * Get the default singleton instance (uses CACHE_TMP_PATH) + * + * @return self + */ + private static function getDefault() { + if (!self::$defaultInstance) { + self::$defaultInstance = new self(CACHE_TMP_PATH); + } + return self::$defaultInstance; + } - /** - * Get the default singleton instance (uses CACHE_TMP_PATH) - * - * @return self - */ - private static function getDefault() { - if (!self::$defaultInstance) { - self::$defaultInstance = new self(CACHE_TMP_PATH); - } - return self::$defaultInstance; - } + /** + * Static write — drop-in for CoreUtilities::setCache() + * + * @param string $key Cache key + * @param mixed $data Data to cache + * @return bool + */ + public static function setCache(string $key, mixed $data) { + return self::getDefault()->set($key, $data); + } - /** - * Static write — drop-in for CoreUtilities::setCache() - * - * @param string $key Cache key - * @param mixed $data Data to cache - * @return bool - */ - public static function setCache($key, $data) { - return self::getDefault()->set($key, $data); - } - - /** - * Static read — drop-in for CoreUtilities::getCache() - * - * @param string $key Cache key - * @param int|null $maxAge Maximum age in seconds (null = no limit) - * @return mixed|false - */ - public static function getCache($key, $maxAge = null) { - return self::getDefault()->get($key, $maxAge); - } + /** + * Static read — drop-in for CoreUtilities::getCache() + * + * @param string $key Cache key + * @param int|null $maxAge Maximum age in seconds (null = no limit) + * @return mixed|false + */ + public static function getCache(string $key, ?int $maxAge = null) { + return self::getDefault()->get($key, $maxAge); + } } diff --git a/src/Core/Cache/RedisCache.php b/src/Core/Cache/RedisCache.php index c45224c8..dd384304 100644 --- a/src/Core/Cache/RedisCache.php +++ b/src/Core/Cache/RedisCache.php @@ -41,217 +41,216 @@ use XcVm\Infrastructure\Redis\RedisManager; */ class RedisCache implements CacheInterface { + /** @var \Redis|null phpredis connection */ + protected $redis = null; - /** @var \Redis|null phpredis connection */ - protected $redis = null; + /** @var string \Redis host */ + protected $host; - /** @var string \Redis host */ - protected $host; + /** @var int \Redis port */ + protected $port; - /** @var int \Redis port */ - protected $port; + /** @var string|null \Redis password */ + protected $password; - /** @var string|null \Redis password */ - protected $password; + /** @var bool Whether connection is established */ + protected $connected = false; - /** @var bool Whether connection is established */ - protected $connected = false; + /** @var string Key prefix to avoid collisions */ + protected $prefix = ''; - /** @var string Key prefix to avoid collisions */ - protected $prefix = ''; + /** + * @param string $host \Redis host + * @param int $port \Redis port + * @param string|null $password \Redis AUTH password + * @param string $prefix Optional key prefix + */ + public function __construct(string $host = '127.0.0.1', int $port = 6379, ?string $password = null, string $prefix = '') { + $this->host = $host; + $this->port = $port; + $this->password = $password; + $this->prefix = $prefix; + } - /** - * @param string $host \Redis host - * @param int $port \Redis port - * @param string|null $password \Redis AUTH password - * @param string $prefix Optional key prefix - */ - public function __construct($host = '127.0.0.1', $port = 6379, $password = null, $prefix = '') { - $this->host = $host; - $this->port = $port; - $this->password = $password; - $this->prefix = $prefix; - } + /** + * Establish \Redis connection (lazy — called on first operation) + * + * @return bool + */ + public function connect() { + if ($this->connected && $this->redis !== null) { + return true; + } - /** - * Establish \Redis connection (lazy — called on first operation) - * - * @return bool - */ - public function connect() { - if ($this->connected && $this->redis !== null) { - return true; - } + try { + $this->redis = new \Redis(); + $this->redis->connect($this->host, $this->port); - try { - $this->redis = new \Redis(); - $this->redis->connect($this->host, $this->port); + if ($this->password) { + $this->redis->auth($this->password); + } - if ($this->password) { - $this->redis->auth($this->password); - } + // Use igbinary serializer if available for consistency with FileCache + if (defined('Redis::SERIALIZER_IGBINARY')) { + $this->redis->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_IGBINARY); + } - // Use igbinary serializer if available for consistency with FileCache - if (defined('Redis::SERIALIZER_IGBINARY')) { - $this->redis->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_IGBINARY); - } + $this->connected = true; + return true; + } catch (\Exception $e) { + $this->redis = null; + $this->connected = false; + return false; + } + } - $this->connected = true; - return true; - } catch (\Exception $e) { - $this->redis = null; - $this->connected = false; - return false; - } - } + /** + * {@inheritdoc} + */ + public function get($key, $maxAge = null) { + if (!$this->ensureConnected()) { + return false; + } - /** - * {@inheritdoc} - */ - public function get($key, $maxAge = null) { - if (!$this->ensureConnected()) { - return false; - } + $prefixedKey = $this->prefix . $key; + $data = $this->redis->get($prefixedKey); - $prefixedKey = $this->prefix . $key; - $data = $this->redis->get($prefixedKey); + if ($data === false) { + return false; + } - if ($data === false) { - return false; - } + // maxAge is handled by \Redis TTL, not by us + // But if caller wants to check age, we can't — \Redis doesn't store creation time + // For file-based TTL compat, we ignore maxAge here (\Redis uses its own TTL) - // maxAge is handled by \Redis TTL, not by us - // But if caller wants to check age, we can't — \Redis doesn't store creation time - // For file-based TTL compat, we ignore maxAge here (\Redis uses its own TTL) + return $data; + } - return $data; - } + /** + * {@inheritdoc} + */ + public function set($key, $data, $ttl = 0) { + if (!$this->ensureConnected()) { + return false; + } - /** - * {@inheritdoc} - */ - public function set($key, $data, $ttl = 0) { - if (!$this->ensureConnected()) { - return false; - } + $prefixedKey = $this->prefix . $key; - $prefixedKey = $this->prefix . $key; + if ($ttl > 0) { + return $this->redis->setex($prefixedKey, $ttl, $data); + } - if ($ttl > 0) { - return $this->redis->setex($prefixedKey, $ttl, $data); - } + return $this->redis->set($prefixedKey, $data); + } - return $this->redis->set($prefixedKey, $data); - } + /** + * {@inheritdoc} + */ + public function delete($key) { + if (!$this->ensureConnected()) { + return false; + } - /** - * {@inheritdoc} - */ - public function delete($key) { - if (!$this->ensureConnected()) { - return false; - } + $prefixedKey = $this->prefix . $key; + $this->redis->del($prefixedKey); - $prefixedKey = $this->prefix . $key; - $this->redis->del($prefixedKey); + return true; + } - return true; - } + /** + * {@inheritdoc} + */ + public function has($key, $maxAge = null) { + if (!$this->ensureConnected()) { + return false; + } - /** - * {@inheritdoc} - */ - public function has($key, $maxAge = null) { - if (!$this->ensureConnected()) { - return false; - } + $prefixedKey = $this->prefix . $key; - $prefixedKey = $this->prefix . $key; + return (bool) $this->redis->exists($prefixedKey); + } - return (bool) $this->redis->exists($prefixedKey); - } + /** + * {@inheritdoc} + * + * WARNING: Flushes the ENTIRE \Redis database. Use with caution. + */ + public function flush() { + if (!$this->ensureConnected()) { + return false; + } - /** - * {@inheritdoc} - * - * WARNING: Flushes the ENTIRE \Redis database. Use with caution. - */ - public function flush() { - if (!$this->ensureConnected()) { - return false; - } + return $this->redis->flushDB(); + } - return $this->redis->flushDB(); - } + // ─────────────────────────────────────────────────────────── + // Raw \Redis Access (for migration period) + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Raw \Redis Access (for migration period) - // ─────────────────────────────────────────────────────────── + /** + * Get the raw phpredis connection + * + * Allows legacy code to use \Redis-specific operations (sorted sets, + * pipelines, pub/sub, etc.) that don't fit the CacheInterface. + * + * @return \Redis|null + */ + public function getConnection() { + $this->ensureConnected(); + return $this->redis; + } - /** - * Get the raw phpredis connection - * - * Allows legacy code to use \Redis-specific operations (sorted sets, - * pipelines, pub/sub, etc.) that don't fit the CacheInterface. - * - * @return \Redis|null - */ - public function getConnection() { - $this->ensureConnected(); - return $this->redis; - } + /** + * Check if connection is alive + * + * @return bool + */ + public function isConnected() { + if (!$this->connected || !$this->redis) { + return false; + } - /** - * Check if connection is alive - * - * @return bool - */ - public function isConnected() { - if (!$this->connected || !$this->redis) { - return false; - } + try { + return $this->redis->ping() !== false; + } catch (\Exception $e) { + $this->connected = false; + return false; + } + } - try { - return $this->redis->ping() !== false; - } catch (\Exception $e) { - $this->connected = false; - return false; - } - } + /** + * Close connection + */ + public function disconnect() { + if ($this->redis) { + try { + $this->redis->close(); + } catch (\Exception $e) { + // ignore + } + } - /** - * Close connection - */ - public function disconnect() { - if ($this->redis) { - try { - $this->redis->close(); - } catch (\Exception $e) { - // ignore - } - } + $this->redis = null; + $this->connected = false; + } - $this->redis = null; - $this->connected = false; - } + /** + * Ensure connection is active, reconnect if needed + * + * @return bool + */ + protected function ensureConnected() { + if ($this->connected && $this->redis !== null) { + return true; + } - /** - * Ensure connection is active, reconnect if needed - * - * @return bool - */ - protected function ensureConnected() { - if ($this->connected && $this->redis !== null) { - return true; - } + return $this->connect(); + } - return $this->connect(); - } - - /** - * Disconnect from \Redis when the instance is destroyed. - */ - public function __destruct() { - $this->disconnect(); - } + /** + * Disconnect from \Redis when the instance is destroyed. + */ + public function __destruct() { + $this->disconnect(); + } } diff --git a/src/Core/Config/AppConfig.php b/src/Core/Config/AppConfig.php index c4d70659..39b944d6 100644 --- a/src/Core/Config/AppConfig.php +++ b/src/Core/Config/AppConfig.php @@ -32,14 +32,14 @@ define('DEV_MODE', false); define('XC_VM_VERSION', '2.5.1'); -define('GIT_OWNER', 'Vateron-Media'); -define('GIT_REPO_MAIN', 'XC_VM'); +define('GIT_OWNER', 'Vateron-Media'); +define('GIT_REPO_MAIN', 'XC_VM'); define('GIT_REPO_UPDATE', 'XC_VM_Update'); -define('GIT_REPO_BIN', 'XC_VM_Binaries'); +define('GIT_REPO_BIN', 'XC_VM_Binaries'); define('GIT_REPO_FANOUT', 'XC_VM_Fanout'); // xc_fanout daemon: source repo, binaries as release assets -define('GIT_REPO_PROXY', 'XC_VM_Proxy'); +define('GIT_REPO_PROXY', 'XC_VM_Proxy'); // ── Miscellaneous Settings ───────────────────────────────────── define('MONITOR_CALLS', 3); // Number of retry attempts for monitoring tasks -define('OPENSSL_EXTRA', 'fNiu3XD448xTDa27xoY4'); // Additional OpenSSL entropy/seed (review necessity) \ No newline at end of file +define('OPENSSL_EXTRA', 'fNiu3XD448xTDa27xoY4'); // Additional OpenSSL entropy/seed (review necessity) diff --git a/src/Core/Config/Binaries.php b/src/Core/Config/Binaries.php index e32a2be5..350dffb1 100644 --- a/src/Core/Config/Binaries.php +++ b/src/Core/Config/Binaries.php @@ -1,6 +1,5 @@ / by // FfmpegPaths (paths) and FfmpegBinaries (discovery + capabilities) — no more // per-version constants to keep in sync with what actually ships in the folder. -define('FFMPEG_BIN_40', BIN_PATH . 'ffmpeg_bin/4.0/ffmpeg'); +define('FFMPEG_BIN_40', BIN_PATH . 'ffmpeg_bin/4.0/ffmpeg'); define('FFPROBE_BIN_40', BIN_PATH . 'ffmpeg_bin/4.0/ffprobe'); diff --git a/src/Core/Config/ConfigReader.php b/src/Core/Config/ConfigReader.php index fcff4ec6..8b61728c 100644 --- a/src/Core/Config/ConfigReader.php +++ b/src/Core/Config/ConfigReader.php @@ -39,7 +39,7 @@ class ConfigReader { * @param mixed $default Значение по умолчанию * @return mixed */ - public static function get(string $key, $default = null) { + public static function get(string $key, mixed $default = null) { return self::getAll()[$key] ?? $default; } } diff --git a/src/Core/Config/DomainResolver.php b/src/Core/Config/DomainResolver.php index 0aeeb9a9..e549b948 100644 --- a/src/Core/Config/DomainResolver.php +++ b/src/Core/Config/DomainResolver.php @@ -27,7 +27,7 @@ class DomainResolver { * @param bool $rForceSSL Force the https protocol. * @return string Public base URL (trailing slash), or '' if no proxy is available. */ - public static function resolve($rServerID, $rForceSSL = false) { + public static function resolve(int $rServerID, bool $rForceSSL = false) { global $rServers, $rSettings; $rOriginatorID = null; if ($rForceSSL) { @@ -62,14 +62,14 @@ class DomainResolver { } if ($rProxied || $rSettings['use_mdomain_in_lists'] == 1) { - $rResellerDomains = CacheReader::get('reseller_domains') ?: array(); + $rResellerDomains = CacheReader::get('reseller_domains') ?: []; if (!(strlen($rDomain) > 0 && in_array(strtolower($rDomain), $rResellerDomains))) { if (empty($rServers[$rServerID]['domain_name'])) { $rDomain = escapeshellcmd($rServers[$rServerID]['server_ip']); - } else if (filter_var($rDomain, FILTER_VALIDATE_IP)) { + } elseif (filter_var($rDomain, FILTER_VALIDATE_IP)) { $rDomain = escapeshellcmd($rServers[$rServerID]['server_ip']); } else { - $rDomain = str_replace(array('http://', '/', 'https://'), '', escapeshellcmd(explode(',', $rServers[$rServerID]['domain_name'])[0])); + $rDomain = str_replace(['http://', '/', 'https://'], '', escapeshellcmd(explode(',', $rServers[$rServerID]['domain_name'])[0])); } } } else { @@ -77,7 +77,7 @@ class DomainResolver { if (empty($rServers[$rServerID]['domain_name'])) { $rDomain = escapeshellcmd($rServers[$rServerID]['server_ip']); } else { - $rDomain = str_replace(array('http://', '/', 'https://'), '', escapeshellcmd(explode(',', $rServers[$rServerID]['domain_name'])[0])); + $rDomain = str_replace(['http://', '/', 'https://'], '', escapeshellcmd(explode(',', $rServers[$rServerID]['domain_name'])[0])); } } } diff --git a/src/Core/Config/Paths.php b/src/Core/Config/Paths.php index 61cdb19b..73dc8e0d 100644 --- a/src/Core/Config/Paths.php +++ b/src/Core/Config/Paths.php @@ -25,66 +25,66 @@ // ───────────────────────────────────────────────────────────────── if (!defined('CONTENT_PATH')) { - define('CONTENT_PATH', MAIN_HOME . 'content/'); + define('CONTENT_PATH', MAIN_HOME . 'content/'); } if (!defined('TMP_PATH')) { - define('TMP_PATH', MAIN_HOME . 'tmp/'); + define('TMP_PATH', MAIN_HOME . 'tmp/'); } // ───────────────────────────────────────────────────────────────── // 2. Системные директории // ───────────────────────────────────────────────────────────────── -define('CONFIG_PATH', MAIN_HOME . 'config/'); -define('BIN_PATH', MAIN_HOME . 'bin/'); -define('STORAGE_PATH', MAIN_HOME . 'storage/'); -define('SIGNALS_PATH', MAIN_HOME . 'signals/'); +define('CONFIG_PATH', MAIN_HOME . 'config/'); +define('BIN_PATH', MAIN_HOME . 'bin/'); +define('STORAGE_PATH', MAIN_HOME . 'storage/'); +define('SIGNALS_PATH', MAIN_HOME . 'signals/'); // xc_fanout daemon sockets (ADR 0002, P2/P3). Kept in the app bin tree next to // the daemon binary, mirroring the php-fpm sockets layout (bin/php/sockets/): // FANOUT_HTTP_SOCK is the nginx-facing client surface, FANOUT_CTL_SOCK the // PHP-only control surface. A unix socket stores no stream bytes (IPC only), so // this is orthogonal to the tmpfs-free byte-path goal. -define('FANOUT_RUN_PATH', BIN_PATH . 'xc_fanout/sockets/'); -define('FANOUT_CTL_SOCK', FANOUT_RUN_PATH . 'control.sock'); +define('FANOUT_RUN_PATH', BIN_PATH . 'xc_fanout/sockets/'); +define('FANOUT_CTL_SOCK', FANOUT_RUN_PATH . 'control.sock'); define('FANOUT_HTTP_SOCK', FANOUT_RUN_PATH . 'http.sock'); // ───────────────────────────────────────────────────────────────── // 3. Контент-директории // ───────────────────────────────────────────────────────────────── -define('STREAMS_PATH', CONTENT_PATH . 'streams/'); -define('EPG_PATH', CONTENT_PATH . 'epg/'); -define('VOD_PATH', CONTENT_PATH . 'vod/'); -define('ARCHIVE_PATH', CONTENT_PATH . 'archive/'); -define('CREATED_PATH', CONTENT_PATH . 'created/'); -define('DELAY_PATH', CONTENT_PATH . 'delayed/'); -define('VIDEO_PATH', CONTENT_PATH . 'video/'); +define('STREAMS_PATH', CONTENT_PATH . 'streams/'); +define('EPG_PATH', CONTENT_PATH . 'epg/'); +define('VOD_PATH', CONTENT_PATH . 'vod/'); +define('ARCHIVE_PATH', CONTENT_PATH . 'archive/'); +define('CREATED_PATH', CONTENT_PATH . 'created/'); +define('DELAY_PATH', CONTENT_PATH . 'delayed/'); +define('VIDEO_PATH', CONTENT_PATH . 'video/'); define('PLAYLIST_PATH', CONTENT_PATH . 'playlists/'); // ───────────────────────────────────────────────────────────────── // 4. Временные директории // ───────────────────────────────────────────────────────────────── -define('CONS_TMP_PATH', TMP_PATH . 'opened_cons/'); -define('CRONS_TMP_PATH', TMP_PATH . 'crons/'); -define('CIDR_TMP_PATH', TMP_PATH . 'cidr/'); -define('CACHE_TMP_PATH', TMP_PATH . 'cache/'); -define('STREAMS_TMP_PATH', TMP_PATH . 'cache/streams/'); -define('SERIES_TMP_PATH', TMP_PATH . 'cache/series/'); -define('LINES_TMP_PATH', TMP_PATH . 'cache/lines/'); +define('CONS_TMP_PATH', TMP_PATH . 'opened_cons/'); +define('CRONS_TMP_PATH', TMP_PATH . 'crons/'); +define('CIDR_TMP_PATH', TMP_PATH . 'cidr/'); +define('CACHE_TMP_PATH', TMP_PATH . 'cache/'); +define('STREAMS_TMP_PATH', TMP_PATH . 'cache/streams/'); +define('SERIES_TMP_PATH', TMP_PATH . 'cache/series/'); +define('LINES_TMP_PATH', TMP_PATH . 'cache/lines/'); define('DIVERGENCE_TMP_PATH', TMP_PATH . 'divergence/'); -define('FLOOD_TMP_PATH', TMP_PATH . 'flood/'); -define('PLAYER_TMP_PATH', TMP_PATH . 'player/'); -define('MINISTRA_TMP_PATH', TMP_PATH . 'ministra/'); -define('SIGNALS_TMP_PATH', TMP_PATH . 'signals/'); -define('LOGS_TMP_PATH', TMP_PATH . 'logs/'); -define('WATCH_TMP_PATH', TMP_PATH . 'watch/'); +define('FLOOD_TMP_PATH', TMP_PATH . 'flood/'); +define('PLAYER_TMP_PATH', TMP_PATH . 'player/'); +define('MINISTRA_TMP_PATH', TMP_PATH . 'ministra/'); +define('SIGNALS_TMP_PATH', TMP_PATH . 'signals/'); +define('LOGS_TMP_PATH', TMP_PATH . 'logs/'); +define('WATCH_TMP_PATH', TMP_PATH . 'watch/'); // ───────────────────────────────────────────────────────────────── // 5. Хранилище файлов // ───────────────────────────────────────────────────────────────── -define('IMAGES_PATH', STORAGE_PATH . 'images/'); +define('IMAGES_PATH', STORAGE_PATH . 'images/'); define('E2_IMAGES_PATH', IMAGES_PATH . 'enigma2/'); diff --git a/src/Core/Config/SettingsManager.php b/src/Core/Config/SettingsManager.php index 4eef7fe7..00a06994 100644 --- a/src/Core/Config/SettingsManager.php +++ b/src/Core/Config/SettingsManager.php @@ -16,7 +16,7 @@ namespace XcVm\Core\Config; class SettingsManager { /** @var array */ - private static $settings = array(); + private static $settings = []; /** * Сохраняет весь массив настроек. @@ -35,11 +35,9 @@ class SettingsManager { /** * Возвращает значение по ключу. * - * @param string $key - * @param mixed $default * @return mixed */ - public static function get(string $key, $default = null) { + public static function get(string $key, mixed $default = null) { return self::$settings[$key] ?? $default; } @@ -53,7 +51,6 @@ class SettingsManager { /** * Проверяет наличие ключа в настройках. * - * @param string $key * @return bool */ public static function has(string $key): bool { @@ -66,7 +63,6 @@ class SettingsManager { * Повторяет PHP-truthiness существующих проверок `if (getAll()['key'])`: * '0' и '' → false, '1' и любое непустое значение → true. * - * @param string $key * @param bool $default Значение, если ключ отсутствует. * @return bool */ @@ -77,8 +73,6 @@ class SettingsManager { /** * Возвращает значение как int. * - * @param string $key - * @param int $default * @return int */ public static function getInt(string $key, int $default = 0): int { @@ -88,8 +82,6 @@ class SettingsManager { /** * Возвращает значение как строку. * - * @param string $key - * @param string $default * @return string */ public static function getString(string $key, string $default = ''): string { @@ -102,11 +94,9 @@ class SettingsManager { * JSON-поля декодируются в массивы ещё в SettingsRepository, поэтому здесь * достаточно проверить тип; для скаляров/null возвращается $default. * - * @param string $key - * @param array $default * @return array */ - public static function getArray(string $key, array $default = array()): array { + public static function getArray(string $key, array $default = []): array { $rValue = self::$settings[$key] ?? null; return is_array($rValue) ? $rValue : $default; } diff --git a/src/Core/Config/SettingsRepository.php b/src/Core/Config/SettingsRepository.php index 5be5b7d2..d53194cd 100644 --- a/src/Core/Config/SettingsRepository.php +++ b/src/Core/Config/SettingsRepository.php @@ -21,7 +21,7 @@ class SettingsRepository { * @param bool $rForce Bypass the file cache and re-read from the database. * @return array Settings map (with normalized array fields). */ - public static function getAll($rForce = false) { + public static function getAll(bool $rForce = false) { global $db; if (!$rForce) { $rCache = FileCache::getCache('settings', 20); @@ -30,17 +30,17 @@ class SettingsRepository { } } - $rOutput = array(); + $rOutput = []; $db->query('SELECT * FROM `settings`'); $rRows = $db->get_row(); - foreach ($rRows ?: array() as $rKey => $rValue) { + foreach ($rRows ?: [] as $rKey => $rValue) { $rOutput[$rKey] = $rValue; } $rOutput['allow_countries'] = json_decode($rOutput['allow_countries'] ?? '', true); $decodedAllowedSTB = json_decode($rOutput['allowed_stb_types'] ?? '', true); - $rOutput['allowed_stb_types'] = array(); + $rOutput['allowed_stb_types'] = []; if (is_array($decodedAllowedSTB)) { // Drop blank entries so an "empty" selection (an unset multiselect is // commonly stored as [""]) collapses to a truly empty array. An empty diff --git a/src/Core/Container/Psr/ContainerExceptionInterface.php b/src/Core/Container/Psr/ContainerExceptionInterface.php index eb912286..d85a0e83 100644 --- a/src/Core/Container/Psr/ContainerExceptionInterface.php +++ b/src/Core/Container/Psr/ContainerExceptionInterface.php @@ -10,4 +10,5 @@ namespace XcVm\Core\Container\Psr; * @package XC_VM_Core_Container * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -interface ContainerExceptionInterface extends \Throwable {} +interface ContainerExceptionInterface extends \Throwable { +} diff --git a/src/Core/Container/Psr/ContainerInterface.php b/src/Core/Container/Psr/ContainerInterface.php index e063f402..870c2253 100644 --- a/src/Core/Container/Psr/ContainerInterface.php +++ b/src/Core/Container/Psr/ContainerInterface.php @@ -11,18 +11,17 @@ namespace XcVm\Core\Container\Psr; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface ContainerInterface { + /** + * Finds an entry of the container by its identifier and returns it. + * + * @param string $id Identifier of the entry to look for. + * @throws NotFoundExceptionInterface No entry was found for this identifier. + * @throws ContainerExceptionInterface Error while retrieving the entry. + */ + public function get(string $id): mixed; - /** - * Finds an entry of the container by its identifier and returns it. - * - * @param string $id Identifier of the entry to look for. - * @throws NotFoundExceptionInterface No entry was found for this identifier. - * @throws ContainerExceptionInterface Error while retrieving the entry. - */ - public function get(string $id): mixed; - - /** - * Returns true if the container can return an entry for the given identifier. - */ - public function has(string $id): bool; + /** + * Returns true if the container can return an entry for the given identifier. + */ + public function has(string $id): bool; } diff --git a/src/Core/Container/Psr/NotFoundException.php b/src/Core/Container/Psr/NotFoundException.php index 4e9f1549..eeb134aa 100644 --- a/src/Core/Container/Psr/NotFoundException.php +++ b/src/Core/Container/Psr/NotFoundException.php @@ -10,4 +10,5 @@ use XcVm\Core\Exception\Container\ContainerException; * @package XC_VM_Core_Container * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -class NotFoundException extends ContainerException implements NotFoundExceptionInterface {} +class NotFoundException extends ContainerException implements NotFoundExceptionInterface { +} diff --git a/src/Core/Container/Psr/NotFoundExceptionInterface.php b/src/Core/Container/Psr/NotFoundExceptionInterface.php index 8ffd4468..8d1f37c5 100644 --- a/src/Core/Container/Psr/NotFoundExceptionInterface.php +++ b/src/Core/Container/Psr/NotFoundExceptionInterface.php @@ -10,4 +10,5 @@ namespace XcVm\Core\Container\Psr; * @package XC_VM_Core_Container * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -interface NotFoundExceptionInterface extends ContainerExceptionInterface {} +interface NotFoundExceptionInterface extends ContainerExceptionInterface { +} diff --git a/src/Core/Container/ServiceContainer.php b/src/Core/Container/ServiceContainer.php index dca09e34..f92714e1 100644 --- a/src/Core/Container/ServiceContainer.php +++ b/src/Core/Container/ServiceContainer.php @@ -87,470 +87,465 @@ use XcVm\Core\Exception\XcVmException; */ class ServiceContainer implements ContainerInterface { + private static ?self $instance = null; - private static ?self $instance = null; + /** @var array */ + private array $factories = []; - /** @var array */ - private array $factories = []; + /** @var array */ + private array $resolved = []; - /** @var array */ - private array $resolved = []; + private array $isFactory = []; - private array $isFactory = []; + private array $creating = []; - private array $creating = []; + /** @var array */ + private array $tags = []; - /** @var array */ - private array $tags = []; + /** + * Decoration chains: id => priority => decorator[] + * Each decorator is a class-string or callable(inner, container): mixed + * @var array>> + */ + private array $decorators = []; - /** - * Decoration chains: id => priority => decorator[] - * Each decorator is a class-string or callable(inner, container): mixed - * @var array>> - */ - private array $decorators = []; + /** + * Services that modules are not allowed to decorate. + * @var string[] + */ + private array $protectedServices = ['db', 'settings', 'config', 'auth']; - /** - * Services that modules are not allowed to decorate. - * @var string[] - */ - private array $protectedServices = ['db', 'settings', 'config', 'auth']; + // ───────────────────────────────────────────────────────── + // Singleton + // ───────────────────────────────────────────────────────── - // ───────────────────────────────────────────────────────── - // Singleton - // ───────────────────────────────────────────────────────── + /** + * Получить единственный экземпляр. + * + * @internal Bootstrap only. Modules receive ServiceContainer via boot(ServiceContainer $c). + */ + public static function getInstance(): self { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } - /** - * Получить единственный экземпляр. - * - * @internal Bootstrap only. Modules receive ServiceContainer via boot(ServiceContainer $c). - */ - public static function getInstance(): self { - if (self::$instance === null) { - self::$instance = new self(); - } - return self::$instance; - } + /** + * Сбросить контейнер (для тестов). + */ + public static function resetInstance(): void { + if (self::$instance !== null) { + self::$instance->factories = []; + self::$instance->resolved = []; + self::$instance->isFactory = []; + self::$instance->creating = []; + self::$instance->tags = []; + } + self::$instance = null; + } - /** - * Сбросить контейнер (для тестов). - */ - public static function resetInstance(): void { - if (self::$instance !== null) { - self::$instance->factories = []; - self::$instance->resolved = []; - self::$instance->isFactory = []; - self::$instance->creating = []; - self::$instance->tags = []; - } - self::$instance = null; - } + /** + * Приватный конструктор (singleton). + */ + private function __construct() { + } - /** - * Приватный конструктор (singleton). - */ - private function __construct() { - } + // ───────────────────────────────────────────────────────── + // Регистрация + // ───────────────────────────────────────────────────────── - // ───────────────────────────────────────────────────────── - // Регистрация - // ───────────────────────────────────────────────────────── + /** + * Зарегистрировать сервис. + * + * Если $value — callable (замыкание или [class, method]), + * он будет вызван ОДИН раз при первом get(). Результат кэшируется. + * + * Если $value — не callable, сохраняется как готовое значение. + * + * @param string $id Уникальный идентификатор (например, 'db', 'settings') + * @param mixed $value Фабрика (callable) или готовое значение + * @return $this + */ + public function set(string $id, mixed $value): static { + // Удаляем ранее разрешённый сервис при перерегистрации + unset($this->resolved[$id]); + unset($this->isFactory[$id]); - /** - * Зарегистрировать сервис. - * - * Если $value — callable (замыкание или [class, method]), - * он будет вызван ОДИН раз при первом get(). Результат кэшируется. - * - * Если $value — не callable, сохраняется как готовое значение. - * - * @param string $id Уникальный идентификатор (например, 'db', 'settings') - * @param mixed $value Фабрика (callable) или готовое значение - * @return $this - */ - public function set(string $id, mixed $value): static { - // Удаляем ранее разрешённый сервис при перерегистрации - unset($this->resolved[$id]); - unset($this->isFactory[$id]); + if (is_callable($value) && !is_string($value) && !is_array($value)) { + // Замыкание → ленивая фабрика (singleton) + $this->factories[$id] = $value; + } else { + // Готовое значение → сразу в resolved + $this->resolved[$id] = $value; + } - if (is_callable($value) && !is_string($value) && !is_array($value)) { - // Замыкание → ленивая фабрика (singleton) - $this->factories[$id] = $value; - } else { - // Готовое значение → сразу в resolved - $this->resolved[$id] = $value; - } + return $this; + } - return $this; - } + /** + * Зарегистрировать фабричный сервис (новый экземпляр при каждом get). + * + * @param string $id Идентификатор + * @param callable $factory Фабрика: function(ServiceContainer $c): mixed + * @return $this + */ + public function factory(string $id, callable $factory): static { + unset($this->resolved[$id]); + $this->factories[$id] = $factory; + $this->isFactory[$id] = true; - /** - * Зарегистрировать фабричный сервис (новый экземпляр при каждом get). - * - * @param string $id Идентификатор - * @param callable $factory Фабрика: function(ServiceContainer $c): mixed - * @return $this - */ - public function factory(string $id, callable $factory): static { - unset($this->resolved[$id]); - $this->factories[$id] = $factory; - $this->isFactory[$id] = true; + return $this; + } - return $this; - } + /** + * Добавить тег к сервису. + * + * Теги позволяют группировать связанные сервисы и получать их пакетно. + * Используются модульной системой для сбора event subscribers, + * cron-задач, маршрутов и т.д. + * + * @param string $id Идентификатор сервиса + * @param string $tag Имя тега (например, 'event.subscriber', 'cron') + * @return $this + */ + public function tag(string $id, string $tag): static { + if (!isset($this->tags[$tag])) { + $this->tags[$tag] = []; + } + if (!in_array($id, $this->tags[$tag], true)) { + $this->tags[$tag][] = $id; + } - /** - * Добавить тег к сервису. - * - * Теги позволяют группировать связанные сервисы и получать их пакетно. - * Используются модульной системой для сбора event subscribers, - * cron-задач, маршрутов и т.д. - * - * @param string $id Идентификатор сервиса - * @param string $tag Имя тега (например, 'event.subscriber', 'cron') - * @return $this - */ - public function tag(string $id, string $tag): static { - if (!isset($this->tags[$tag])) { - $this->tags[$tag] = []; - } - if (!in_array($id, $this->tags[$tag], true)) { - $this->tags[$tag][] = $id; - } + return $this; + } - return $this; - } + /** + * Wrap a service with a decorator. + * + * The decorator receives the inner service as first constructor argument (class-string) + * or as the first callable argument (callable form). + * + * Multiple decorators on the same service are applied in priority order: + * highest priority wraps outermost (called first by callers). + * + * Example: + * $c->decorate('stream.service', \FingerprintDecorator::class, priority: 20); + * $c->decorate('stream.service', \LoggingDecorator::class, priority: 10); + * // Call order: \FingerprintDecorator → \LoggingDecorator → original + * + * @param string $id Service identifier + * @param class-string|callable $decorator Class-string or callable($inner, $container): mixed + * @param int $priority Higher = outer layer (default 0) + * @throws \RuntimeException If the service is protected or not registered as a factory + */ + public function decorate(string $id, string|callable $decorator, int $priority = 0): static { + if (in_array($id, $this->protectedServices, true)) { + throw new ContainerException( + "ServiceContainer: сервис '{$id}' защищён от декорирования модулями." + ); + } - /** - * Wrap a service with a decorator. - * - * The decorator receives the inner service as first constructor argument (class-string) - * or as the first callable argument (callable form). - * - * Multiple decorators on the same service are applied in priority order: - * highest priority wraps outermost (called first by callers). - * - * Example: - * $c->decorate('stream.service', \FingerprintDecorator::class, priority: 20); - * $c->decorate('stream.service', \LoggingDecorator::class, priority: 10); - * // Call order: \FingerprintDecorator → \LoggingDecorator → original - * - * @param string $id Service identifier - * @param class-string|callable $decorator Class-string or callable($inner, $container): mixed - * @param int $priority Higher = outer layer (default 0) - * @throws \RuntimeException If the service is protected or not registered as a factory - */ - public function decorate(string $id, string|callable $decorator, int $priority = 0): static { - if (in_array($id, $this->protectedServices, true)) { - throw new ContainerException( - "ServiceContainer: сервис '{$id}' защищён от декорирования модулями." - ); - } + if (!isset($this->factories[$id]) && !array_key_exists($id, $this->resolved)) { + throw new ContainerException( + "ServiceContainer: невозможно декорировать незарегистрированный сервис '{$id}'." + ); + } - if (!isset($this->factories[$id]) && !array_key_exists($id, $this->resolved)) { - throw new ContainerException( - "ServiceContainer: невозможно декорировать незарегистрированный сервис '{$id}'." - ); - } + $this->decorators[$id][$priority][] = $decorator; + unset($this->resolved[$id]); // rebuild chain on next get() - $this->decorators[$id][$priority][] = $decorator; - unset($this->resolved[$id]); // rebuild chain on next get() + return $this; + } - return $this; - } + /** + * Return decorator layers for a service (for debugging). + * + * @return array> priority => decorators[] + */ + public function getDecoratorChain(string $id): array { + return $this->decorators[$id] ?? []; + } - /** - * Return decorator layers for a service (for debugging). - * - * @param string $id - * @return array> priority => decorators[] - */ - public function getDecoratorChain(string $id): array { - return $this->decorators[$id] ?? []; - } + // ───────────────────────────────────────────────────────── + // Получение + // ───────────────────────────────────────────────────────── - // ───────────────────────────────────────────────────────── - // Получение - // ───────────────────────────────────────────────────────── + /** + * Получить сервис по идентификатору. + * + * @param string $id Идентификатор + * @return mixed + * @throws NotFoundException Если сервис не зарегистрирован + * @throws \RuntimeException Если обнаружена циклическая зависимость или фабрика бросила исключение + */ + public function get(string $id): mixed { + // 1. Уже разрешён (singleton) — мгновенный возврат + if (array_key_exists($id, $this->resolved) && empty($this->isFactory[$id])) { + return $this->resolved[$id]; + } - /** - * Получить сервис по идентификатору. - * - * @param string $id Идентификатор - * @return mixed - * @throws NotFoundException Если сервис не зарегистрирован - * @throws \RuntimeException Если обнаружена циклическая зависимость или фабрика бросила исключение - */ - public function get(string $id): mixed { - // 1. Уже разрешён (singleton) — мгновенный возврат - if (array_key_exists($id, $this->resolved) && empty($this->isFactory[$id])) { - return $this->resolved[$id]; - } + // 2. Есть фабрика — вызываем + if (isset($this->factories[$id])) { + // Детекция циклических зависимостей + if (isset($this->creating[$id])) { + $this->throwCircularDependency($id); + } - // 2. Есть фабрика — вызываем - if (isset($this->factories[$id])) { - // Детекция циклических зависимостей - if (isset($this->creating[$id])) { - $this->throwCircularDependency($id); - } + $this->creating[$id] = true; - $this->creating[$id] = true; + try { + $service = call_user_func($this->factories[$id], $this); + $service = $this->applyDecorators($id, $service); + } catch (XcVmException $e) { + unset($this->creating[$id]); + throw $e; + } catch (\Exception $e) { + unset($this->creating[$id]); + throw new ServiceCreationException( + "ServiceContainer: ошибка при создании сервиса '{$id}': " . $e->getMessage(), + 0, + $e + ); + } - try { - $service = call_user_func($this->factories[$id], $this); - $service = $this->applyDecorators($id, $service); - } catch (XcVmException $e) { - unset($this->creating[$id]); - throw $e; - } catch (\Exception $e) { - unset($this->creating[$id]); - throw new ServiceCreationException( - "ServiceContainer: ошибка при создании сервиса '{$id}': " . $e->getMessage(), - 0, - $e - ); - } + unset($this->creating[$id]); - unset($this->creating[$id]); + // Фабричные сервисы не кэшируются + if (empty($this->isFactory[$id])) { + $this->resolved[$id] = $service; + } - // Фабричные сервисы не кэшируются - if (empty($this->isFactory[$id])) { - $this->resolved[$id] = $service; - } + return $service; + } - return $service; - } + throw new NotFoundException( + "ServiceContainer: сервис '{$id}' не зарегистрирован. " + . "Доступные сервисы: " . implode(', ', $this->keys()) + ); + } - throw new NotFoundException( - "ServiceContainer: сервис '{$id}' не зарегистрирован. " - . "Доступные сервисы: " . implode(', ', $this->keys()) - ); - } + /** + * Получить сервис или вернуть значение по умолчанию. + * + * @param string $id Идентификатор + * @param mixed $default Значение по умолчанию (если сервис не найден) + * @return mixed + */ + public function getOrDefault(string $id, mixed $default = null): mixed { + if ($this->has($id)) { + return $this->get($id); + } + return $default; + } - /** - * Получить сервис или вернуть значение по умолчанию. - * - * @param string $id Идентификатор - * @param mixed $default Значение по умолчанию (если сервис не найден) - * @return mixed - */ - public function getOrDefault(string $id, mixed $default = null): mixed { - if ($this->has($id)) { - return $this->get($id); - } - return $default; - } + /** + * Получить все сервисы с указанным тегом. + * + * @param string $tag Имя тега + * @return array Массив сервисов [id => service] + */ + public function getTagged(string $tag): array { + $services = []; + if (isset($this->tags[$tag])) { + foreach ($this->tags[$tag] as $id) { + if ($this->has($id)) { + $services[$id] = $this->get($id); + } + } + } + return $services; + } - /** - * Получить все сервисы с указанным тегом. - * - * @param string $tag Имя тега - * @return array Массив сервисов [id => service] - */ - public function getTagged(string $tag): array { - $services = []; - if (isset($this->tags[$tag])) { - foreach ($this->tags[$tag] as $id) { - if ($this->has($id)) { - $services[$id] = $this->get($id); - } - } - } - return $services; - } + /** + * Проверить, зарегистрирован ли сервис. + * + * @param string $id Идентификатор + * @return bool + */ + public function has(string $id): bool { + return array_key_exists($id, $this->resolved) || isset($this->factories[$id]); + } - /** - * Проверить, зарегистрирован ли сервис. - * - * @param string $id Идентификатор - * @return bool - */ - public function has(string $id): bool { - return array_key_exists($id, $this->resolved) || isset($this->factories[$id]); - } + /** + * Список всех зарегистрированных идентификаторов. + * + * @return string[] + */ + public function keys(): array { + return array_unique( + array_merge( + array_keys($this->resolved), + array_keys($this->factories) + ) + ); + } - /** - * Список всех зарегистрированных идентификаторов. - * - * @return string[] - */ - public function keys(): array { - return array_unique( - array_merge( - array_keys($this->resolved), - array_keys($this->factories) - ) - ); - } + /** + * Удалить сервис из контейнера. + * + * @param string $id Идентификатор + * @return $this + */ + public function remove(string $id): static { + unset( + $this->factories[$id], + $this->resolved[$id], + $this->isFactory[$id] + ); - /** - * Удалить сервис из контейнера. - * - * @param string $id Идентификатор - * @return $this - */ - public function remove(string $id): static { - unset( - $this->factories[$id], - $this->resolved[$id], - $this->isFactory[$id] - ); + // Удалить из тегов + foreach ($this->tags as &$ids) { + $ids = array_values(array_filter($ids, function ($v) use ($id) { + return $v !== $id; + })); + } - // Удалить из тегов - foreach ($this->tags as &$ids) { - $ids = array_values(array_filter($ids, function ($v) use ($id) { - return $v !== $id; - })); - } + return $this; + } - return $this; - } + // ───────────────────────────────────────────────────────── + // Массовая регистрация + // ───────────────────────────────────────────────────────── - // ───────────────────────────────────────────────────────── - // Массовая регистрация - // ───────────────────────────────────────────────────────── + /** + * Зарегистрировать несколько сервисов из массива. + * + * @param array $services Массив [id => value/callable, ...] + * @return $this + */ + public function register(array $services): static { + foreach ($services as $id => $value) { + $this->set($id, $value); + } + return $this; + } - /** - * Зарегистрировать несколько сервисов из массива. - * - * @param array $services Массив [id => value/callable, ...] - * @return $this - */ - public function register(array $services): static { - foreach ($services as $id => $value) { - $this->set($id, $value); - } - return $this; - } + // ───────────────────────────────────────────────────────── + // ArrayAccess-подобный синтаксис (без implements — не нужны интерфейсы) + // ───────────────────────────────────────────────────────── - // ───────────────────────────────────────────────────────── - // ArrayAccess-подобный синтаксис (без implements — не нужны интерфейсы) - // ───────────────────────────────────────────────────────── + /** + * Магический доступ: $container->db вместо $container->get('db') + * + * @return mixed + */ + public function __get(string $id): mixed { + return $this->get($id); + } - /** - * Магический доступ: $container->db вместо $container->get('db') - * - * @param string $id - * @return mixed - */ - public function __get(string $id): mixed { - return $this->get($id); - } + /** + * Магическая проверка: isset($container->db) + * + * @return bool + */ + public function __isset(string $id): bool { + return $this->has($id); + } - /** - * Магическая проверка: isset($container->db) - * - * @param string $id - * @return bool - */ - public function __isset(string $id): bool { - return $this->has($id); - } + // ───────────────────────────────────────────────────────── + // Decoration + // ───────────────────────────────────────────────────────── - // ───────────────────────────────────────────────────────── - // Decoration - // ───────────────────────────────────────────────────────── + /** + * Apply registered decorators to a freshly created service. + * + * Decorators are sorted by priority descending so the highest-priority + * decorator becomes the outermost wrapper (first to intercept callers). + * + * @param string $id Service identifier + * @param mixed $service The base service instance + * @return mixed Decorated service (or original if no decorators registered) + */ + private function applyDecorators(string $id, mixed $service): mixed { + if (empty($this->decorators[$id])) { + return $service; + } - /** - * Apply registered decorators to a freshly created service. - * - * Decorators are sorted by priority descending so the highest-priority - * decorator becomes the outermost wrapper (first to intercept callers). - * - * @param string $id Service identifier - * @param mixed $service The base service instance - * @return mixed Decorated service (or original if no decorators registered) - */ - private function applyDecorators(string $id, mixed $service): mixed { - if (empty($this->decorators[$id])) { - return $service; - } + $buckets = $this->decorators[$id]; + krsort($buckets); // highest priority first = outermost wrapper last in application order - $buckets = $this->decorators[$id]; - krsort($buckets); // highest priority first = outermost wrapper last in application order + // Build inside-out: iterate from lowest to highest priority + // so the highest-priority decorator ends up as the outermost layer. + $layers = array_merge(...array_reverse(array_values($buckets))); - // Build inside-out: iterate from lowest to highest priority - // so the highest-priority decorator ends up as the outermost layer. - $layers = array_merge(...array_reverse(array_values($buckets))); + foreach ($layers as $decorator) { + if (is_string($decorator)) { + $service = new $decorator($service); + } else { + $service = $decorator($service, $this); + } + } - foreach ($layers as $decorator) { - if (is_string($decorator)) { - $service = new $decorator($service); - } else { - $service = $decorator($service, $this); - } - } + return $service; + } - return $service; - } + // ───────────────────────────────────────────────────────── + // Internal throw helpers (never return type — R2-4) + // ───────────────────────────────────────────────────────── - // ───────────────────────────────────────────────────────── - // Internal throw helpers (never return type — R2-4) - // ───────────────────────────────────────────────────────── + /** + * Throw a CircularDependencyException describing the resolution chain. + * + * @param string $id Service id whose creation closed the cycle. + * @return never + * @throws CircularDependencyException Always. + */ + private function throwCircularDependency(string $id): never { + throw new CircularDependencyException( + "ServiceContainer: циклическая зависимость при создании сервиса '{$id}'. " + . "Цепочка: " . implode(' → ', array_keys($this->creating)) . " → {$id}" + ); + } - /** - * Throw a CircularDependencyException describing the resolution chain. - * - * @param string $id Service id whose creation closed the cycle. - * @return never - * @throws CircularDependencyException Always. - */ - private function throwCircularDependency(string $id): never { - throw new CircularDependencyException( - "ServiceContainer: циклическая зависимость при создании сервиса '{$id}'. " - . "Цепочка: " . implode(' → ', array_keys($this->creating)) . " → {$id}" - ); - } + // ───────────────────────────────────────────────────────── + // Отладка + // ───────────────────────────────────────────────────────── - // ───────────────────────────────────────────────────────── - // Отладка - // ───────────────────────────────────────────────────────── + /** + * Дамп содержимого контейнера (для отладки). + * + * @return array + */ + public function dump(): array { + $result = []; + foreach ($this->keys() as $id) { + $status = 'pending'; + $type = 'unknown'; - /** - * Дамп содержимого контейнера (для отладки). - * - * @return array - */ - public function dump(): array { - $result = []; - foreach ($this->keys() as $id) { - $status = 'pending'; - $type = 'unknown'; + if (array_key_exists($id, $this->resolved)) { + $status = 'resolved'; + $type = is_object($this->resolved[$id]) + ? get_class($this->resolved[$id]) + : gettype($this->resolved[$id]); + } elseif (isset($this->factories[$id])) { + $status = !empty($this->isFactory[$id]) ? 'factory' : 'lazy'; + $type = 'callable'; + } - if (array_key_exists($id, $this->resolved)) { - $status = 'resolved'; - $type = is_object($this->resolved[$id]) - ? get_class($this->resolved[$id]) - : gettype($this->resolved[$id]); - } elseif (isset($this->factories[$id])) { - $status = !empty($this->isFactory[$id]) ? 'factory' : 'lazy'; - $type = 'callable'; - } + $result[$id] = [ + 'status' => $status, + 'type' => $type, + 'tags' => $this->getTagsFor($id), + ]; + } - $result[$id] = [ - 'status' => $status, - 'type' => $type, - 'tags' => $this->getTagsFor($id), - ]; - } + ksort($result); + return $result; + } - ksort($result); - return $result; - } - - /** - * Получить все теги для сервиса. - * - * @param string $id - * @return string[] - */ - private function getTagsFor(string $id): array { - $result = []; - foreach ($this->tags as $tag => $ids) { - if (in_array($id, $ids, true)) { - $result[] = $tag; - } - } - return $result; - } + /** + * Получить все теги для сервиса. + * + * @return string[] + */ + private function getTagsFor(string $id): array { + $result = []; + foreach ($this->tags as $tag => $ids) { + if (in_array($id, $ids, true)) { + $result[] = $tag; + } + } + return $result; + } } diff --git a/src/Core/Database/Database.php b/src/Core/Database/Database.php index aa55d242..25aa80c1 100644 --- a/src/Core/Database/Database.php +++ b/src/Core/Database/Database.php @@ -16,17 +16,24 @@ use XcVm\Core\Logging\FileLogger; class Database { public $result = null; + public $last_query = null; + public $dbh = null; + public $connected = false; /** Last PDO error message (empty when the last query succeeded). */ protected $lastError = ''; protected $dbuser = null; + protected $dbpassword = null; + protected $dbname = null; + protected $dbhost = null; + protected $dbport = null; /** @@ -38,7 +45,7 @@ class Database { * @param string $host Database host * @param int $db_port Database port number */ - public function __construct($db_user = null, $db_pass = null, $db_name = null, $host = null, $db_port = 3306, $migrate = false) { + public function __construct(string $db_user = null, string $db_pass = null, string $db_name = null, string $host = null, int $db_port = 3306, $migrate = false) { $this->dbh = false; $this->dbuser = $db_user; $this->dbpassword = $db_pass; @@ -54,7 +61,7 @@ class Database { * @param string $rHost Host name. * @return string '127.0.0.1' for 'localhost', otherwise the host unchanged. */ - private function normalizeHost($rHost) { + private function normalizeHost(string $rHost) { if ($rHost === 'localhost') { return '127.0.0.1'; } @@ -122,7 +129,7 @@ class Database { * null it defaults to $migrate (legacy behaviour). * @return bool True on success. */ - public function db_connect($migrate = false, $graceful = null) { + public function db_connect(bool $migrate = false, ?bool $graceful = null) { if ($graceful === null) { $graceful = $migrate; } @@ -131,14 +138,14 @@ class Database { $this->dbh = \XC_VM::db_connect($migrate); if (!$this->dbh) { if (!$graceful) { - exit(json_encode(array('error' => 'MySQL: Cannot connect to database! Please check credentials.'))); + exit(json_encode(['error' => 'MySQL: Cannot connect to database! Please check credentials.'])); } return false; } } catch (\PDOException $e) { if (!$graceful) { - exit(json_encode(array('error' => 'MySQL: ' . $e->getMessage()))); + exit(json_encode(['error' => 'MySQL: ' . $e->getMessage()])); } return false; } @@ -184,7 +191,7 @@ class Database { * @param string $rPassword Password. * @return bool True on success, false on failure. */ - public function db_explicit_connect($rHost, $rPort, $rDatabase, $rUsername, $rPassword) { + public function db_explicit_connect(string $rHost, int $rPort, string $rDatabase, string $rUsername, string $rPassword) { try { $this->dbh = new \PDO('mysql:host=' . $this->normalizeHost($rHost) . ';port=' . $rPort . ';dbname=' . $rDatabase, $rUsername, $rPassword); } catch (\PDOException $e) { @@ -204,7 +211,7 @@ class Database { * @param \PDOStatement $stmt Prepared statement. * @return string The dumped parameter/SQL debug text. */ - public function debugString($stmt) { + public function debugString(\PDOStatement $stmt) { ob_start(); $stmt->debugDumpParams(); $r = ob_get_contents(); @@ -230,7 +237,7 @@ class Database { * for extraction into a dedicated unbuffered_query(). * @return bool True on success, false on failure. */ - public function query($query, $buffered = false) { + public function query(string $query, mixed $buffered = false) { if (!$this->dbh) { return false; } @@ -238,7 +245,7 @@ class Database { $numargs = func_num_args(); $arg_list = func_get_args(); - $next_arg_list = array(); + $next_arg_list = []; $i = 1; while ($i < $numargs) { @@ -301,7 +308,7 @@ class Database { * @param string $query Raw SQL. * @return bool True on success, false on failure. */ - public function simple_query($query) { + public function simple_query(string $query) { try { $this->result = $this->dbh->query($query); } catch (\Exception $e) { @@ -321,19 +328,19 @@ class Database { * @param string $sub_row_id Optional column used as the sub-key when grouping. * @return array|false Rows (cleaned), or false if no active result. */ - public function get_rows($use_id = false, $column_as_id = '', $unique_row = true, $sub_row_id = '') { + public function get_rows(bool $use_id = false, string $column_as_id = '', bool $unique_row = true, string $sub_row_id = '') { if (!($this->dbh && $this->result)) { return false; } - $rows = array(); + $rows = []; if (0 >= $this->result->rowCount()) { } else { foreach ($this->result->fetchAll(\PDO::FETCH_ASSOC) as $row) { if ($use_id && array_key_exists($column_as_id, $row)) { if (!isset($rows[$row[$column_as_id]])) { - $rows[$row[$column_as_id]] = array(); + $rows[$row[$column_as_id]] = []; } if (!$unique_row) { @@ -366,7 +373,7 @@ class Database { return false; } - $row = array(); + $row = []; if (0 >= $this->result->rowCount()) { } else { @@ -426,7 +433,7 @@ class Database { * @param string $string Value to quote. * @return string|null Quoted string, or null if not connected. */ - public function escape($string) { + public function escape(string $string) { if ($this->dbh) { return $this->dbh->quote($string); } @@ -483,9 +490,9 @@ class Database { * @param string $rValue Raw value. * @return string Cleaned value ('' for empty input). */ - public static function parseCleanValue($rValue) { + public static function parseCleanValue(string $rValue) { if ($rValue != '') { - $rValue = str_replace(array("\r\n", "\n\r", "\r"), "\n", $rValue); + $rValue = str_replace(["\r\n", "\n\r", "\r"], "\n", $rValue); $rValue = str_replace('<', '<', str_replace('>', '>', $rValue)); $rValue = str_replace('', '-->', $rValue); @@ -504,7 +511,7 @@ class Database { * @param array $row Associative row. * @return array Row with sanitized values. */ - public function clean_row($row) { + public function clean_row(array $row) { foreach ($row as $key => $value) { if ($value) { $row[$key] = self::parseCleanValue($value); diff --git a/src/Core/Database/DatabaseHandler.php b/src/Core/Database/DatabaseHandler.php index 2a4cb010..0e74c65c 100644 --- a/src/Core/Database/DatabaseHandler.php +++ b/src/Core/Database/DatabaseHandler.php @@ -70,462 +70,461 @@ namespace XcVm\Core\Database; */ class DatabaseHandler extends Database { + /** @var int Total queries executed in this request */ + protected $queryCount = 0; - /** @var int Total queries executed in this request */ - protected $queryCount = 0; + /** @var float Total query execution time (seconds) */ + protected $queryTime = 0.0; - /** @var float Total query execution time (seconds) */ - protected $queryTime = 0.0; + /** @var bool Whether a transaction is currently active */ + protected $inTransaction = false; - /** @var bool Whether a transaction is currently active */ - protected $inTransaction = false; + /** @var callable|null Optional logger callback: function(string $level, string $message, array $context) */ + protected $logger = null; - /** @var callable|null Optional logger callback: function(string $level, string $message, array $context) */ - protected $logger = null; + /** @var int Maximum reconnection attempts */ + protected $maxReconnectAttempts = 3; - /** @var int Maximum reconnection attempts */ - protected $maxReconnectAttempts = 3; + /** + * Factory method — creates DatabaseHandler from config array + * + * @param array $config Associative array with keys: + * 'username', 'password', 'database', 'hostname', 'port' (optional, default 3306) + * @param bool $migrate Whether this is a migration connection (no exit on failure) + * @return DatabaseHandler|false + */ + public static function create(array $config, bool $migrate = false) { + $port = isset($config['port']) ? (int) $config['port'] : 3306; - /** - * Factory method — creates DatabaseHandler from config array - * - * @param array $config Associative array with keys: - * 'username', 'password', 'database', 'hostname', 'port' (optional, default 3306) - * @param bool $migrate Whether this is a migration connection (no exit on failure) - * @return DatabaseHandler|false - */ - public static function create(array $config, $migrate = false) { - $port = isset($config['port']) ? (int)$config['port'] : 3306; + return new self( + $config['username'], + $config['password'], + $config['database'], + $config['hostname'], + $port, + $migrate + ); + } - return new self( - $config['username'], - $config['password'], - $config['database'], - $config['hostname'], - $port, - $migrate - ); - } + /** + * Set a logger callback for query and error logging + * + * @param callable $logger function(string $level, string $message, array $context = []) + * @return $this + */ + public function setLogger(callable $logger) { + $this->logger = $logger; + return $this; + } - /** - * Set a logger callback for query and error logging - * - * @param callable $logger function(string $level, string $message, array $context = []) - * @return $this - */ - public function setLogger($logger) { - $this->logger = $logger; - return $this; - } + // ─────────────────────────────────────────────────────────── + // Transaction Support + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Transaction Support - // ─────────────────────────────────────────────────────────── + /** + * Begin a database transaction + * + * @return bool + */ + public function beginTransaction() { + if (!$this->dbh) { + return false; + } - /** - * Begin a database transaction - * - * @return bool - */ - public function beginTransaction() { - if (!$this->dbh) { - return false; - } + if ($this->inTransaction) { + $this->log('warning', 'beginTransaction called while already in transaction'); + return false; + } - if ($this->inTransaction) { - $this->log('warning', 'beginTransaction called while already in transaction'); - return false; - } + $this->inTransaction = $this->dbh->beginTransaction(); + return $this->inTransaction; + } - $this->inTransaction = $this->dbh->beginTransaction(); - return $this->inTransaction; - } + /** + * Commit the current transaction + * + * @return bool + */ + public function commit() { + if (!$this->dbh || !$this->inTransaction) { + return false; + } - /** - * Commit the current transaction - * - * @return bool - */ - public function commit() { - if (!$this->dbh || !$this->inTransaction) { - return false; - } + $result = $this->dbh->commit(); + $this->inTransaction = false; + return $result; + } - $result = $this->dbh->commit(); - $this->inTransaction = false; - return $result; - } + /** + * Rollback the current transaction + * + * @return bool + */ + public function rollback() { + if (!$this->dbh || !$this->inTransaction) { + return false; + } - /** - * Rollback the current transaction - * - * @return bool - */ - public function rollback() { - if (!$this->dbh || !$this->inTransaction) { - return false; - } + $result = $this->dbh->rollBack(); + $this->inTransaction = false; + return $result; + } - $result = $this->dbh->rollBack(); - $this->inTransaction = false; - return $result; - } + /** + * Execute a callback within a transaction + * + * Automatically commits on success, rolls back on anything thrown — an Error + * (a TypeError, an undefined method) as well as an Exception, or the + * transaction would stay open on the connection. + * + * @param callable $callback function(DatabaseHandler $db) + * @return mixed The return value of the callback + * @throws \Throwable Re-throws after rollback + */ + public function transactional(callable $callback) { + $this->beginTransaction(); - /** - * Execute a callback within a transaction - * - * Automatically commits on success, rolls back on anything thrown — an Error - * (a TypeError, an undefined method) as well as an Exception, or the - * transaction would stay open on the connection. - * - * @param callable $callback function(DatabaseHandler $db) - * @return mixed The return value of the callback - * @throws \Throwable Re-throws after rollback - */ - public function transactional($callback) { - $this->beginTransaction(); + try { + $result = $callback($this); + $this->commit(); + return $result; + } catch (\Throwable $e) { + $this->rollback(); + throw $e; + } + } - try { - $result = $callback($this); - $this->commit(); - return $result; - } catch (\Throwable $e) { - $this->rollback(); - throw $e; - } - } + /** + * Check if currently inside a transaction + * + * @return bool + */ + public function isInTransaction() { + return $this->inTransaction; + } - /** - * Check if currently inside a transaction - * - * @return bool - */ - public function isInTransaction() { - return $this->inTransaction; - } + // ─────────────────────────────────────────────────────────── + // Improved Query Methods (shorthand) + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Improved Query Methods (shorthand) - // ─────────────────────────────────────────────────────────── + /** + * Execute query and return all rows + * + * Shorthand for: $db->query(...); return $db->get_rows(); + * + * @param string $sql SQL query with ? placeholders + * @param mixed ...$params Bound parameter values + * @return array|false + */ + public function fetchAll(string $sql, mixed ...$params) { + $args = func_get_args(); + if (!call_user_func_array([$this, 'query'], $args)) { + return false; + } + return $this->get_rows(); + } - /** - * Execute query and return all rows - * - * Shorthand for: $db->query(...); return $db->get_rows(); - * - * @param string $sql SQL query with ? placeholders - * @param mixed ...$params Bound parameter values - * @return array|false - */ - public function fetchAll($sql, ...$params) { - $args = func_get_args(); - if (!call_user_func_array(array($this, 'query'), $args)) { - return false; - } - return $this->get_rows(); - } + /** + * Execute query and return all rows keyed by a column + * + * Shorthand for: $db->query(...); return $db->get_rows(true, $column); + * + * @param string $keyColumn Column name to use as array key + * @param string $sql SQL query with ? placeholders + * @param mixed ...$params Bound parameter values + * @return array|false + */ + public function fetchAllKeyed(string $keyColumn, string $sql, mixed ...$params) { + $args = func_get_args(); + array_shift($args); // remove $keyColumn + if (!call_user_func_array([$this, 'query'], $args)) { + return false; + } + return $this->get_rows(true, $keyColumn); + } - /** - * Execute query and return all rows keyed by a column - * - * Shorthand for: $db->query(...); return $db->get_rows(true, $column); - * - * @param string $keyColumn Column name to use as array key - * @param string $sql SQL query with ? placeholders - * @param mixed ...$params Bound parameter values - * @return array|false - */ - public function fetchAllKeyed($keyColumn, $sql, ...$params) { - $args = func_get_args(); - array_shift($args); // remove $keyColumn - if (!call_user_func_array(array($this, 'query'), $args)) { - return false; - } - return $this->get_rows(true, $keyColumn); - } + /** + * Execute query and return single row + * + * Shorthand for: $db->query(...); return $db->get_row(); + * + * @param string $sql SQL query with ? placeholders + * @param mixed ...$params Bound parameter values + * @return array|false + */ + public function fetchOne(string $sql, mixed ...$params) { + $args = func_get_args(); + if (!call_user_func_array([$this, 'query'], $args)) { + return false; + } + return $this->get_row(); + } - /** - * Execute query and return single row - * - * Shorthand for: $db->query(...); return $db->get_row(); - * - * @param string $sql SQL query with ? placeholders - * @param mixed ...$params Bound parameter values - * @return array|false - */ - public function fetchOne($sql, ...$params) { - $args = func_get_args(); - if (!call_user_func_array(array($this, 'query'), $args)) { - return false; - } - return $this->get_row(); - } + /** + * Execute query and return a single scalar value + * + * Shorthand for: $db->query(...); return $db->get_col(); + * + * @param string $sql SQL query with ? placeholders + * @param mixed ...$params Bound parameter values + * @return mixed|false + */ + public function fetchValue(string $sql, mixed ...$params) { + $args = func_get_args(); + if (!call_user_func_array([$this, 'query'], $args)) { + return false; + } + return $this->get_col(); + } - /** - * Execute query and return a single scalar value - * - * Shorthand for: $db->query(...); return $db->get_col(); - * - * @param string $sql SQL query with ? placeholders - * @param mixed ...$params Bound parameter values - * @return mixed|false - */ - public function fetchValue($sql, ...$params) { - $args = func_get_args(); - if (!call_user_func_array(array($this, 'query'), $args)) { - return false; - } - return $this->get_col(); - } + /** + * Execute query and return a single column as flat array + * + * Shorthand for: $db->query(...); return $db->get_column(); + * + * @param string $sql SQL query with ? placeholders + * @param mixed ...$params Bound parameter values + * @return array|false + */ + public function fetchColumn(string $sql, mixed ...$params) { + $args = func_get_args(); + if (!call_user_func_array([$this, 'query'], $args)) { + return false; + } + return $this->get_column(); + } - /** - * Execute query and return a single column as flat array - * - * Shorthand for: $db->query(...); return $db->get_column(); - * - * @param string $sql SQL query with ? placeholders - * @param mixed ...$params Bound parameter values - * @return array|false - */ - public function fetchColumn($sql, ...$params) { - $args = func_get_args(); - if (!call_user_func_array(array($this, 'query'), $args)) { - return false; - } - return $this->get_column(); - } + // ─────────────────────────────────────────────────────────── + // Query Override with Timing and Reconnect + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Query Override with Timing and Reconnect - // ─────────────────────────────────────────────────────────── + /** + * Execute a prepared query with automatic reconnection and timing + * + * Overrides Database::query() to add: + * - Query counting + * - Execution time tracking + * - Automatic reconnection on "MySQL server has gone away" + * + * @param string $query SQL with ? placeholders + * @param mixed $buffered First bind value, or boolean true to disable + * buffered query mode (legacy positional overload; + * all args from index 1 are collected as binds). + * @return bool + */ + public function query(string $query, mixed $buffered = false) { + $start = microtime(true); + $this->queryCount++; - /** - * Execute a prepared query with automatic reconnection and timing - * - * Overrides Database::query() to add: - * - Query counting - * - Execution time tracking - * - Automatic reconnection on "MySQL server has gone away" - * - * @param string $query SQL with ? placeholders - * @param mixed $buffered First bind value, or boolean true to disable - * buffered query mode (legacy positional overload; - * all args from index 1 are collected as binds). - * @return bool - */ - public function query($query, $buffered = false) { - $start = microtime(true); - $this->queryCount++; + $result = call_user_func_array('parent::query', func_get_args()); - $result = call_user_func_array('parent::query', func_get_args()); + // If query failed — try reconnection (only if not in transaction) + if ($result === false && !$this->inTransaction && $this->shouldReconnect()) { + $this->log('warning', 'Query failed, attempting reconnect', ['query' => $query]); - // If query failed — try reconnection (only if not in transaction) - if ($result === false && !$this->inTransaction && $this->shouldReconnect()) { - $this->log('warning', 'Query failed, attempting reconnect', ['query' => $query]); + if ($this->reconnect()) { + $result = call_user_func_array('parent::query', func_get_args()); + } + } - if ($this->reconnect()) { - $result = call_user_func_array('parent::query', func_get_args()); - } - } + $elapsed = microtime(true) - $start; + $this->queryTime += $elapsed; - $elapsed = microtime(true) - $start; - $this->queryTime += $elapsed; + return $result; + } - return $result; - } + // ─────────────────────────────────────────────────────────── + // Connection Management + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Connection Management - // ─────────────────────────────────────────────────────────── + /** + * Attempt to reconnect to the database + * + * @return bool + */ + public function reconnect() { + $attempts = 0; - /** - * Attempt to reconnect to the database - * - * @return bool - */ - public function reconnect() { - $attempts = 0; + while ($attempts < $this->maxReconnectAttempts) { + $attempts++; + $this->log('info', "Reconnect attempt {$attempts}/{$this->maxReconnectAttempts}"); - while ($attempts < $this->maxReconnectAttempts) { - $attempts++; - $this->log('info', "Reconnect attempt {$attempts}/{$this->maxReconnectAttempts}"); + $this->close_mysql(); - $this->close_mysql(); + // Reconnect to the MAIN configured schema, gracefully (no exit()) so + // the retry/backoff loop can continue. migrate=true here would + // silently reconnect to `xc_vm_migrate` instead of the panel DB. + if ($this->db_connect(false, true)) { + $this->log('info', 'Reconnected successfully'); + return true; + } - // Reconnect to the MAIN configured schema, gracefully (no exit()) so - // the retry/backoff loop can continue. migrate=true here would - // silently reconnect to `xc_vm_migrate` instead of the panel DB. - if ($this->db_connect(false, true)) { - $this->log('info', 'Reconnected successfully'); - return true; - } + // Exponential backoff: 100ms, 200ms, 400ms + usleep(100000 * pow(2, $attempts - 1)); + } - // Exponential backoff: 100ms, 200ms, 400ms - usleep(100000 * pow(2, $attempts - 1)); - } + $this->log('error', 'Failed to reconnect after ' . $this->maxReconnectAttempts . ' attempts'); + return false; + } - $this->log('error', 'Failed to reconnect after ' . $this->maxReconnectAttempts . ' attempts'); - return false; - } + /** + * Check if a reconnection should be attempted + * + * @return bool + */ + protected function shouldReconnect() { + if (!$this->dbh) { + return true; + } - /** - * Check if a reconnection should be attempted - * - * @return bool - */ - protected function shouldReconnect() { - if (!$this->dbh) { - return true; - } + return !$this->ping(); + } - return !$this->ping(); - } + // ─────────────────────────────────────────────────────────── + // Diagnostics + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Diagnostics - // ─────────────────────────────────────────────────────────── + /** + * Get total number of queries executed + * + * @return int + */ + public function getQueryCount() { + return $this->queryCount; + } - /** - * Get total number of queries executed - * - * @return int - */ - public function getQueryCount() { - return $this->queryCount; - } + /** + * Get total query execution time in seconds + * + * @return float + */ + public function getQueryTime() { + return $this->queryTime; + } - /** - * Get total query execution time in seconds - * - * @return float - */ - public function getQueryTime() { - return $this->queryTime; - } + /** + * Get diagnostic summary + * + * @return array + */ + public function getDiagnostics() { + return [ + 'connected' => $this->connected, + 'queryCount' => $this->queryCount, + 'queryTime' => round($this->queryTime, 4), + 'inTransaction' => $this->inTransaction, + 'database' => $this->dbname, + 'host' => $this->dbhost, + 'port' => $this->dbport, + ]; + } - /** - * Get diagnostic summary - * - * @return array - */ - public function getDiagnostics() { - return [ - 'connected' => $this->connected, - 'queryCount' => $this->queryCount, - 'queryTime' => round($this->queryTime, 4), - 'inTransaction' => $this->inTransaction, - 'database' => $this->dbname, - 'host' => $this->dbhost, - 'port' => $this->dbport, - ]; - } + // ─────────────────────────────────────────────────────────── + // Bulk Operations + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Bulk Operations - // ─────────────────────────────────────────────────────────── + /** + * Insert a row from an associative array + * + * @param string $table Table name + * @param array $data Associative array of column => value + * @return int|false Last insert ID on success, false on failure + */ + public function insert(string $table, array $data) { + if (empty($data)) { + return false; + } - /** - * Insert a row from an associative array - * - * @param string $table Table name - * @param array $data Associative array of column => value - * @return int|false Last insert ID on success, false on failure - */ - public function insert($table, array $data) { - if (empty($data)) { - return false; - } + $columns = array_keys($data); + $placeholders = array_fill(0, count($columns), '?'); - $columns = array_keys($data); - $placeholders = array_fill(0, count($columns), '?'); + $sql = sprintf( + 'INSERT INTO `%s` (`%s`) VALUES (%s)', + $table, + implode('`, `', $columns), + implode(', ', $placeholders) + ); - $sql = sprintf( - 'INSERT INTO `%s` (`%s`) VALUES (%s)', - $table, - implode('`, `', $columns), - implode(', ', $placeholders) - ); + $args = array_merge([$sql], array_values($data)); - $args = array_merge([$sql], array_values($data)); + if (call_user_func_array([$this, 'query'], $args)) { + return $this->last_insert_id(); + } - if (call_user_func_array([$this, 'query'], $args)) { - return $this->last_insert_id(); - } + return false; + } - return false; - } + /** + * Update rows from an associative array + * + * @param string $table Table name + * @param array $data Associative array of column => value + * @param string $where WHERE clause (with ? placeholders) + * @param mixed ...$whereParams Values for WHERE placeholders + * @return bool + */ + public function update(string $table, array $data, string $where, mixed ...$whereParams) { + if (empty($data)) { + return false; + } - /** - * Update rows from an associative array - * - * @param string $table Table name - * @param array $data Associative array of column => value - * @param string $where WHERE clause (with ? placeholders) - * @param mixed ...$whereParams Values for WHERE placeholders - * @return bool - */ - public function update($table, array $data, $where, ...$whereParams) { - if (empty($data)) { - return false; - } + $setParts = []; + $values = []; - $setParts = []; - $values = []; + foreach ($data as $column => $value) { + $setParts[] = "`{$column}` = ?"; + $values[] = $value; + } - foreach ($data as $column => $value) { - $setParts[] = "`{$column}` = ?"; - $values[] = $value; - } + $sql = sprintf( + 'UPDATE `%s` SET %s WHERE %s', + $table, + implode(', ', $setParts), + $where + ); - $sql = sprintf( - 'UPDATE `%s` SET %s WHERE %s', - $table, - implode(', ', $setParts), - $where - ); + // Add WHERE params from extra func_get_args + $allArgs = func_get_args(); + $whereParams = array_slice($allArgs, 3); + $values = array_merge($values, $whereParams); - // Add WHERE params from extra func_get_args - $allArgs = func_get_args(); - $whereParams = array_slice($allArgs, 3); - $values = array_merge($values, $whereParams); + $args = array_merge([$sql], $values); - $args = array_merge([$sql], $values); + return call_user_func_array([$this, 'query'], $args); + } - return call_user_func_array([$this, 'query'], $args); - } + /** + * Delete rows with a WHERE clause + * + * @param string $table Table name + * @param string $where WHERE clause with ? placeholders + * @param mixed ...$params Values for WHERE placeholders + * @return bool + */ + public function delete(string $table, string $where, mixed ...$params) { + $sql = sprintf('DELETE FROM `%s` WHERE %s', $table, $where); - /** - * Delete rows with a WHERE clause - * - * @param string $table Table name - * @param string $where WHERE clause with ? placeholders - * @param mixed ...$params Values for WHERE placeholders - * @return bool - */ - public function delete($table, $where, ...$params) { - $sql = sprintf('DELETE FROM `%s` WHERE %s', $table, $where); + $allArgs = func_get_args(); + $whereParams = array_slice($allArgs, 2); + $args = array_merge([$sql], $whereParams); - $allArgs = func_get_args(); - $whereParams = array_slice($allArgs, 2); - $args = array_merge([$sql], $whereParams); + return call_user_func_array([$this, 'query'], $args); + } - return call_user_func_array([$this, 'query'], $args); - } + // ─────────────────────────────────────────────────────────── + // Internal Logging + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Internal Logging - // ─────────────────────────────────────────────────────────── - - /** - * Log a message through the configured logger - * - * @param string $level 'info', 'warning', 'error' - * @param string $message Log message - * @param array $context Additional context - */ - protected function log($level, $message, array $context = []) { - if ($this->logger) { - call_user_func($this->logger, $level, '[DatabaseHandler] ' . $message, $context); - } - } + /** + * Log a message through the configured logger + * + * @param string $level 'info', 'warning', 'error' + * @param string $message Log message + * @param array $context Additional context + */ + protected function log(string $level, string $message, array $context = []) { + if ($this->logger) { + call_user_func($this->logger, $level, '[DatabaseHandler] ' . $message, $context); + } + } } diff --git a/src/Core/Database/MigrationRunner.php b/src/Core/Database/MigrationRunner.php index 98e64a25..c612dac9 100644 --- a/src/Core/Database/MigrationRunner.php +++ b/src/Core/Database/MigrationRunner.php @@ -13,7 +13,6 @@ namespace XcVm\Core\Database; */ class MigrationRunner { - /** * Apply pending SQL migrations from the migrations/ directory. * @@ -23,7 +22,7 @@ class MigrationRunner { * @param Database $db Database handle. * @return void */ - public static function run($db): void { + public static function run(Database $db): void { echo "Migrations\n------------------------------\n"; $db->query("CREATE TABLE IF NOT EXISTS `migrations` ( @@ -33,7 +32,7 @@ class MigrationRunner { ) ENGINE=InnoDB DEFAULT CHARSET=utf8;"); $db->query("SELECT `migration` FROM `migrations`;"); - $rApplied = array(); + $rApplied = []; if ($db->num_rows() > 0) { foreach ($db->get_rows() as $rRow) { $rApplied[] = $rRow['migration']; diff --git a/src/Core/Database/QueryHelper.php b/src/Core/Database/QueryHelper.php index e9a4e41f..2f959452 100644 --- a/src/Core/Database/QueryHelper.php +++ b/src/Core/Database/QueryHelper.php @@ -15,7 +15,7 @@ class QueryHelper { * @param string $rValue Raw column/table name. * @return string Sanitized identifier. */ - public static function prepareColumn($rValue) { + public static function prepareColumn(string $rValue) { return strtolower(preg_replace('/[^a-z0-9_]+/i', '', $rValue)); } @@ -27,8 +27,8 @@ class QueryHelper { * @param array $rArray Column => value map. * @return array ['columns' => string, 'placeholder' => string, 'data' => array, 'update' => string]. */ - public static function prepareArray($rArray) { - $UpdateData = $rColumns = $rPlaceholder = $rData = array(); + public static function prepareArray(array $rArray) { + $UpdateData = $rColumns = $rPlaceholder = $rData = []; foreach (array_keys($rArray) as $rKey) { $rColumns[] = '`' . self::prepareColumn($rKey) . '`'; @@ -48,7 +48,7 @@ class QueryHelper { $rData[] = $rValue; } - return array('placeholder' => implode(',', $rPlaceholder), 'columns' => implode(',', $rColumns), 'data' => $rData, 'update' => implode(',', $UpdateData)); + return ['placeholder' => implode(',', $rPlaceholder), 'columns' => implode(',', $rColumns), 'data' => $rData, 'update' => implode(',', $UpdateData)]; } /** @@ -62,9 +62,9 @@ class QueryHelper { * @param bool $rOnlyExisting When true, skip columns absent from $rData. * @return array Sanitized column => value map ready for prepareArray(). */ - public static function verifyPostTable($rTable, $rData = array(), $rOnlyExisting = false) { + public static function verifyPostTable(string $rTable, array $rData = [], bool $rOnlyExisting = false) { global $db; - $rReturn = array(); + $rReturn = []; $db->query('SELECT `column_name`, `column_default`, `is_nullable`, `data_type` FROM `information_schema`.`columns` WHERE `table_schema` = (SELECT DATABASE()) AND `table_name` = ? ORDER BY `ordinal_position`;', $rTable); foreach ($db->get_rows() as $rRow) { @@ -81,7 +81,7 @@ class QueryHelper { if ($rRow['is_nullable'] != 'NO' || $rRow['column_default']) { } else { - if (in_array($rRow['data_type'], array('int', 'float', 'tinyint', 'double', 'decimal', 'smallint', 'mediumint', 'bigint', 'bit'))) { + if (in_array($rRow['data_type'], ['int', 'float', 'tinyint', 'double', 'decimal', 'smallint', 'mediumint', 'bigint', 'bit'])) { $rRow['column_default'] = 0; } else { $rRow['column_default'] = ''; @@ -92,11 +92,11 @@ class QueryHelper { if (array_key_exists($rRow['column_name'], $rData)) { // coerce empty string for numeric columns to a safe default - $rNumericTypes = array('int', 'float', 'tinyint', 'double', 'decimal', 'smallint', 'mediumint', 'bigint', 'bit'); + $rNumericTypes = ['int', 'float', 'tinyint', 'double', 'decimal', 'smallint', 'mediumint', 'bigint', 'bit']; $rValue = $rData[$rRow['column_name']]; if ($rValue === '' && in_array($rRow['data_type'], $rNumericTypes)) { $rReturn[$rRow['column_name']] = is_null($rRow['column_default']) ? ($rForceDefault ? 0 : null) : $rRow['column_default']; - } else if (empty($rValue) && !is_numeric($rValue) && is_null($rRow['column_default'])) { + } elseif (empty($rValue) && !is_numeric($rValue) && is_null($rRow['column_default'])) { $rReturn[$rRow['column_name']] = ($rForceDefault ? $rRow['column_default'] : null); } else { $rReturn[$rRow['column_name']] = $rValue; @@ -122,7 +122,7 @@ class QueryHelper { * @param mixed $rExclude Value to exclude for $rExcludeColumn. * @return bool True if at least one matching row exists. */ - public static function checkExists($rTable, $rColumn, $rValue, $rExcludeColumn = null, $rExclude = null) { + public static function checkExists(string $rTable, string $rColumn, mixed $rValue, ?string $rExcludeColumn = null, mixed $rExclude = null) { global $db; if ($rExcludeColumn && $rExclude) { diff --git a/src/Core/Diagnostics/DiagnosticsService.php b/src/Core/Diagnostics/DiagnosticsService.php index 0b57a73e..a29d348f 100644 --- a/src/Core/Diagnostics/DiagnosticsService.php +++ b/src/Core/Diagnostics/DiagnosticsService.php @@ -21,7 +21,6 @@ use XcVm\Infrastructure\Database\DatabaseAware; */ class DiagnosticsService { - use DatabaseAware; /** @@ -30,7 +29,7 @@ class DiagnosticsService { * @param string|null $certificate Path to certificate file (auto-detects from nginx if null) * @return array|null ['serial', 'expiration', 'subject', 'path'], or null if the certificate is missing/unreadable */ - public static function getCertificateInfo($certificate = null) { + public static function getCertificateInfo(?string $certificate = null) { $result = ['serial' => null, 'expiration' => null, 'subject' => null, 'path' => null]; if (!$certificate) { @@ -74,7 +73,7 @@ class DiagnosticsService { * @param bool $allowHEVC Whether HEVC/H265 + AC3 are allowed * @return bool */ - public static function checkCompatibility($data, $allowHEVC = false) { + public static function checkCompatibility(array|string $data, bool $allowHEVC = false) { if (!is_array($data)) { $data = json_decode($data, true); } @@ -140,9 +139,9 @@ class DiagnosticsService { 'type' => isset($error['type']) ? htmlspecialchars($error['type'], ENT_QUOTES, 'UTF-8') : 'unknown', 'message' => isset($error['log_message']) ? htmlspecialchars($error['log_message'], ENT_QUOTES, 'UTF-8') : '', 'file' => isset($error['log_extra']) ? htmlspecialchars($error['log_extra'], ENT_QUOTES, 'UTF-8') : '', - 'line' => isset($error['line']) ? (int)$error['line'] : 0, - 'date' => isset($error['date']) ? (int)$error['date'] : 0, - 'version' => isset($error['version']) ? htmlspecialchars((string)$error['version'], ENT_QUOTES, 'UTF-8') : '', + 'line' => isset($error['line']) ? (int) $error['line'] : 0, + 'date' => isset($error['date']) ? (int) $error['date'] : 0, + 'version' => isset($error['version']) ? htmlspecialchars((string) $error['version'], ENT_QUOTES, 'UTF-8') : '', ]; try { @@ -202,19 +201,19 @@ class DiagnosticsService { $ids = []; foreach ($rows as $row) { - $ts = isset($row['date']) ? (int)$row['date'] : 0; + $ts = isset($row['date']) ? (int) $row['date'] : 0; $errorsForApi[] = [ 'type' => $row['type'] ?? '', 'log_message' => $row['log_message'] ?? '', 'log_extra' => $row['log_extra'] ?? '', - 'line' => isset($row['line']) ? (string)$row['line'] : '', + 'line' => isset($row['line']) ? (string) $row['line'] : '', 'date' => $ts > 0 ? gmdate('Y-m-d H:i:s', $ts) : '', // Per-error panel version frozen when the error occurred. The log // server attributes the entry to THIS, not the batch/current version. 'version' => (string) ($row['version'] ?? ''), ]; if (isset($row['id'])) { - $ids[] = (int)$row['id']; + $ids[] = (int) $row['id']; } } @@ -257,9 +256,9 @@ class DiagnosticsService { * @param int $rServerID Server id to query. * @return array Process info keyed/listed as returned by the server. */ - public static function getPIDs($rServerID) { - $rReturn = array(); - $rProcesses = json_decode(ApiClient::systemRequest($rServerID, array('action' => 'get_pids')), true); + public static function getPIDs(int $rServerID) { + $rReturn = []; + $rProcesses = json_decode(ApiClient::systemRequest($rServerID, ['action' => 'get_pids']), true); if (!is_array($rProcesses)) { return $rReturn; } @@ -269,7 +268,7 @@ class DiagnosticsService { $rSplit = explode(' ', preg_replace('!\\s+!', ' ', trim($rProcess))); if ($rSplit[0] == 'xc_vm') { - $rUsage = array(0, 0, 0); + $rUsage = [0, 0, 0]; $rTimer = explode('-', $rSplit[9]); if (1 < count($rTimer)) { @@ -322,7 +321,7 @@ class DiagnosticsService { $rUsage[2] = 0; } - $rReturn[] = array('user' => $rSplit[0], 'pid' => $rSplit[1], 'cpu' => $rSplit[2], 'mem' => $rSplit[3], 'vsz' => $rSplit[4], 'rss' => $rSplit[5], 'tty' => $rSplit[6], 'stat' => $rSplit[7], 'time' => $rUsage[1], 'etime' => $rUsage[0], 'load_average' => $rUsage[2], 'command' => implode(' ', array_splice($rSplit, 10, count($rSplit) - 10))); + $rReturn[] = ['user' => $rSplit[0], 'pid' => $rSplit[1], 'cpu' => $rSplit[2], 'mem' => $rSplit[3], 'vsz' => $rSplit[4], 'rss' => $rSplit[5], 'tty' => $rSplit[6], 'stat' => $rSplit[7], 'time' => $rUsage[1], 'etime' => $rUsage[0], 'load_average' => $rUsage[2], 'command' => implode(' ', array_splice($rSplit, 10, count($rSplit) - 10))]; } } @@ -335,9 +334,9 @@ class DiagnosticsService { * @param int $rServerID Server id to inspect. * @return array NVENC process details. */ - public static function getNVENCProcesses($rServerID) { + public static function getNVENCProcesses(int $rServerID) { $db = self::db(); - $rProcesses = array(); + $rProcesses = []; $rServer = ServerRepository::getById($rServerID); $rGPUInfo = json_decode($rServer['gpu_info'], true); @@ -345,7 +344,7 @@ class DiagnosticsService { } else { foreach ($rGPUInfo['gpus'] as $rGPU) { foreach ($rGPU['processes'] as $rProcess) { - $rArray = array('pid' => $rProcess['pid'], 'memory' => $rProcess['memory'], 'stream_id' => null); + $rArray = ['pid' => $rProcess['pid'], 'memory' => $rProcess['memory'], 'stream_id' => null]; $db->query('SELECT `stream_id` FROM `streams_servers` WHERE `pid` = ? AND `server_id` = ?;', $rProcess['pid'], $rServerID); if (0 >= $db->num_rows()) { diff --git a/src/Core/Enum/BootContext.php b/src/Core/Enum/BootContext.php index 9ae93133..9dcd77b0 100644 --- a/src/Core/Enum/BootContext.php +++ b/src/Core/Enum/BootContext.php @@ -13,15 +13,15 @@ namespace XcVm\Core\Enum; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ enum BootContext: string { - /** Constants + config only. No DB. */ - case Minimal = 'minimal'; + /** Constants + config only. No DB. */ + case Minimal = 'minimal'; - /** + Database + LegacyInitializer. For cron jobs and CLI scripts. */ - case Cli = 'cli'; + /** + Database + LegacyInitializer. For cron jobs and CLI scripts. */ + case Cli = 'cli'; - /** + Database (cached). Lightweight path for streaming endpoints. */ - case Stream = 'stream'; + /** + Database (cached). Lightweight path for streaming endpoints. */ + case Stream = 'stream'; - /** Full initialization: DB + API + Translator + session. */ - case Admin = 'admin'; + /** Full initialization: DB + API + Translator + session. */ + case Admin = 'admin'; } diff --git a/src/Core/Enum/ClientFilter.php b/src/Core/Enum/ClientFilter.php index 93537a57..45e9f64c 100644 --- a/src/Core/Enum/ClientFilter.php +++ b/src/Core/Enum/ClientFilter.php @@ -1,6 +1,5 @@ 'Token Failure', - self::NotInBouquet => 'Not in Bouquet', - self::BlockedAsn => 'Blocked ASN', - self::IspLockFailed => 'ISP Lock Failed', - self::UserDisallowExt => 'Extension Disallowed', - self::AuthFailed => 'Authentication Failed', - self::UserExpired => 'User Expired', - self::UserDisabled => 'User Disabled', - self::UserBan => 'User Banned', - self::MagTokenInvalid => 'MAG Token Invalid', - self::StalkerChannelMismatch => 'Stalker Channel Mismatch', - self::StalkerIpMismatch => 'Stalker IP Mismatch', - self::StalkerKeyExpired => 'Stalker Key Expired', - self::StalkerDecryptFailed => 'Stalker Decrypt Failed', - self::EmptyUa => 'Empty User-Agent', - self::IpBan => 'IP Banned', - self::CountryDisallow => 'Country Disallowed', - self::UserAgentBan => 'User-Agent Disallowed', - self::UserAlreadyConnected => 'IP Limit Reached', - self::RestreamDetect => 'Restream Detected', - self::ProxyDetect => 'Proxy / VPN Detected', - self::HostingDetect => 'Hosting Server Detected', - self::LineCreateFail => 'Connection Failed', - self::ConnectionLoop => 'Connection Loop', - self::TokenExpired => 'Token Expired', - self::IpMismatch => 'IP Mismatch', - }; - } + /** + * Human-readable filter label (as shown in the client request log). + */ + public function label(): string { + return match ($this) { + self::LbTokenInvalid => 'Token Failure', + self::NotInBouquet => 'Not in Bouquet', + self::BlockedAsn => 'Blocked ASN', + self::IspLockFailed => 'ISP Lock Failed', + self::UserDisallowExt => 'Extension Disallowed', + self::AuthFailed => 'Authentication Failed', + self::UserExpired => 'User Expired', + self::UserDisabled => 'User Disabled', + self::UserBan => 'User Banned', + self::MagTokenInvalid => 'MAG Token Invalid', + self::StalkerChannelMismatch => 'Stalker Channel Mismatch', + self::StalkerIpMismatch => 'Stalker IP Mismatch', + self::StalkerKeyExpired => 'Stalker Key Expired', + self::StalkerDecryptFailed => 'Stalker Decrypt Failed', + self::EmptyUa => 'Empty User-Agent', + self::IpBan => 'IP Banned', + self::CountryDisallow => 'Country Disallowed', + self::UserAgentBan => 'User-Agent Disallowed', + self::UserAlreadyConnected => 'IP Limit Reached', + self::RestreamDetect => 'Restream Detected', + self::ProxyDetect => 'Proxy / VPN Detected', + self::HostingDetect => 'Hosting Server Detected', + self::LineCreateFail => 'Connection Failed', + self::ConnectionLoop => 'Connection Loop', + self::TokenExpired => 'Token Expired', + self::IpMismatch => 'IP Mismatch', + }; + } - /** - * Resolve a raw status key to its label, falling back to the raw key - * itself when it is unknown (tolerant lookup for log rendering). - */ - public static function labelFor(string $key): string { - return self::tryFrom($key)?->label() ?? $key; - } + /** + * Resolve a raw status key to its label, falling back to the raw key + * itself when it is unknown (tolerant lookup for log rendering). + */ + public static function labelFor(string $key): string { + return self::tryFrom($key)?->label() ?? $key; + } - /** - * Ordered key => label map for building select/filter dropdowns, - * preserving the legacy `$rClientFilters` ordering. - * - * @return array - */ - public static function options(): array { - $options = []; - foreach (self::cases() as $case) { - $options[$case->value] = $case->label(); - } - return $options; - } + /** + * Ordered key => label map for building select/filter dropdowns, + * preserving the legacy `$rClientFilters` ordering. + * + * @return array + */ + public static function options(): array { + $options = []; + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + return $options; + } } diff --git a/src/Core/Enum/ModuleState.php b/src/Core/Enum/ModuleState.php index d212f4d8..e4959629 100644 --- a/src/Core/Enum/ModuleState.php +++ b/src/Core/Enum/ModuleState.php @@ -1,6 +1,5 @@ 'Create', - self::Extend => 'Extend', - self::Convert => 'Convert', - self::Edit => 'Edit', - self::Enable => 'Enable', - self::Disable => 'Disable', - self::Delete => 'Delete', - self::SendEvent => 'MAG Event', - self::AdjustCredits => 'Adjust Credits', - }; - } + /** + * Human-readable action label (as shown in log filters). + */ + public function label(): string { + return match ($this) { + self::New => 'Create', + self::Extend => 'Extend', + self::Convert => 'Convert', + self::Edit => 'Edit', + self::Enable => 'Enable', + self::Disable => 'Disable', + self::Delete => 'Delete', + self::SendEvent => 'MAG Event', + self::AdjustCredits => 'Adjust Credits', + }; + } - /** - * Ordered key => label map for building select/filter dropdowns, - * preserving the legacy `$rResellerActions` ordering. - * - * @return array - */ - public static function options(): array { - $options = []; - foreach (self::cases() as $case) { - $options[$case->value] = $case->label(); - } - return $options; - } + /** + * Ordered key => label map for building select/filter dropdowns, + * preserving the legacy `$rResellerActions` ordering. + * + * @return array + */ + public static function options(): array { + $options = []; + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + return $options; + } } diff --git a/src/Core/Enum/ServerEnvironment.php b/src/Core/Enum/ServerEnvironment.php index 4728a5a4..a3f499ec 100644 --- a/src/Core/Enum/ServerEnvironment.php +++ b/src/Core/Enum/ServerEnvironment.php @@ -14,9 +14,9 @@ namespace XcVm\Core\Enum; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ enum ServerEnvironment: string { - /** Standard panel installation. */ - case Main = 'main'; + /** Standard panel installation. */ + case Main = 'main'; - /** Load-balancer node (SERVER_TYPE=lb). */ - case LoadBalancer = 'lb'; + /** Load-balancer node (SERVER_TYPE=lb). */ + case LoadBalancer = 'lb'; } diff --git a/src/Core/Enum/Theme.php b/src/Core/Enum/Theme.php index e0a0d179..2eb0409c 100644 --- a/src/Core/Enum/Theme.php +++ b/src/Core/Enum/Theme.php @@ -1,6 +1,5 @@ 'Light', - self::Dark => 'Dark', - }; - } + /** + * Human-readable theme name (as shown in the profile selector). + */ + public function label(): string { + return match ($this) { + self::Light => 'Light', + self::Dark => 'Dark', + }; + } - /** - * Resolve a stored profile theme value to a Theme, defaulting to Light - * for missing or out-of-range values. - * - * @param mixed $id The raw `theme` value from the user profile. - */ - public static function fromId(mixed $id): self { - return self::tryFrom((int) $id) ?? self::Light; - } + /** + * Resolve a stored profile theme value to a Theme, defaulting to Light + * for missing or out-of-range values. + * + * @param mixed $id The raw `theme` value from the user profile. + */ + public static function fromId(mixed $id): self { + return self::tryFrom((int) $id) ?? self::Light; + } - /** - * Ordered id => label map for building the theme selector, preserving - * the legacy `$rThemes` ordering (0 => Light, 1 => Dark). - * - * @return array - */ - public static function options(): array { - $options = []; - foreach (self::cases() as $case) { - $options[$case->value] = $case->label(); - } - return $options; - } + /** + * Ordered id => label map for building the theme selector, preserving + * the legacy `$rThemes` ordering (0 => Light, 1 => Dark). + * + * @return array + */ + public static function options(): array { + $options = []; + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + return $options; + } } diff --git a/src/Core/Error/ErrorCodes.php b/src/Core/Error/ErrorCodes.php index bbcd5c50..b39e5f6a 100644 --- a/src/Core/Error/ErrorCodes.php +++ b/src/Core/Error/ErrorCodes.php @@ -18,72 +18,72 @@ */ global $rErrorCodes; -$rErrorCodes = array( - 'API_IP_NOT_ALLOWED' => 'IP is not allowed to access the API.', - 'ARCHIVE_DOESNT_EXIST' => 'Archive files are missing for this stream ID.', - 'ASN_BLOCKED' => 'ASN has been blocked.', - 'BANNED' => 'Line has been banned.', - 'BLOCKED_USER_AGENT' => 'User-agent has been blocked.', - 'DEVICE_NOT_ALLOWED' => 'MAG & Enigma devices are not allowed to access this.', - 'DISABLED' => 'Line has been disabled.', - 'DOWNLOAD_LIMIT_REACHED' => 'Reached the simultaneous download limit.', - 'E2_DEVICE_LOCK_FAILED' => 'Device lock checks failed.', - 'E2_DISABLED' => 'Device has been disabled.', - 'E2_NO_TOKEN' => 'No token has been specified.', - 'E2_TOKEN_DOESNT_MATCH' => "Token doesn't match records.", - 'E2_WATCHDOG_TIMEOUT' => 'Time limit reached.', - 'EMPTY_USER_AGENT' => 'Empty user-agents are disallowed.', - 'EPG_DISABLED' => 'EPG has been disabled.', - 'EPG_FILE_MISSING' => 'Cached EPG files are missing.', - 'EXPIRED' => 'Line has expired.', - 'FORCED_COUNTRY_INVALID' => 'Country does not match forced country.', - 'GENERATE_PLAYLIST_FAILED' => 'Playlist failed to generate.', - 'HLS_DISABLED' => 'HLS has been disabled.', - 'HOSTING_DETECT' => 'Hosting server has been detected.', - 'INVALID_API_PASSWORD' => 'API password is invalid.', - 'INVALID_CREDENTIALS' => 'Username or password is invalid.', - 'INVALID_HOST' => 'Domain name not recognised.', - 'INVALID_STREAM_ID' => "Stream ID doesn't exist.", - 'INVALID_TYPE_TOKEN' => "Tokens can't be used for this stream type.", - 'IP_BLOCKED' => 'IP has been blocked.', - 'IP_MISMATCH' => "Current IP doesn't match initial connection IP.", - 'ISP_BLOCKED' => 'ISP has been blocked.', - 'LB_TOKEN_INVALID' => 'AES Token cannot be decrypted.', - 'LEGACY_EPG_DISABLED' => 'Legacy epg.php access has been disabled.', - 'LEGACY_GET_DISABLED' => 'Legacy get.php access has been disabled.', - 'LEGACY_PANEL_API_DISABLED' => 'Legacy panel_api.php access has been disabled.', - 'LINE_CREATE_FAIL' => 'Line failed to insert into database.', - 'NO_CREDENTIALS' => 'No credentials have been specified.', - 'NO_TIMESTAMP' => 'No archive timestamp has been specified.', - 'NO_TOKEN_SPECIFIED' => 'No AES encrypted token has been specified.', - 'NOT_ENIGMA_DEVICE' => "Line isn't an enigma device.", - 'NOT_IN_ALLOWED_COUNTRY' => 'Not in allowed country list.', - 'NOT_IN_ALLOWED_IPS' => 'Not in allowed IP list.', - 'NOT_IN_ALLOWED_UAS' => 'Not in allowed user-agent list.', - 'NOT_IN_BOUQUET' => "Line doesn't have access to this stream ID.", - 'PLAYER_API_DISABLED' => 'Player API has been disabled.', - 'PROXY_DETECT' => 'Proxy has been detected.', - 'PROXY_NO_API_ACCESS' => "Can't access API's via proxy.", - 'RESTREAM_DETECT' => 'Restreaming has been detected.', - 'STALKER_CHANNEL_MISMATCH' => "Stream ID doesn't match stalker token.", - 'STALKER_DECRYPT_FAILED' => 'Failed to decrypt stalker token.', - 'STALKER_INVALID_KEY' => 'Invalid stalker key.', - 'STALKER_IP_MISMATCH' => "IP doesn't match stalker token.", - 'STALKER_KEY_EXPIRED' => 'Stalker token has expired.', - 'STREAM_OFFLINE' => 'Stream is currently offline.', - 'THUMBNAIL_DOESNT_EXIST' => "Thumbnail file doesn't exist.", - 'THUMBNAILS_NOT_ENABLED' => 'Thumbnail not enabled for this stream.', - 'TOKEN_ERROR' => 'AES token has incomplete data.', - 'TOKEN_EXPIRED' => 'AES token has expired.', - 'TS_DISABLED' => 'MPEG-TS has been disabled.', - 'USER_ALREADY_CONNECTED' => 'Line already connected on a different IP.', - 'USER_DISALLOW_EXT' => 'Extension is not in allowed list.', - 'VOD_DOESNT_EXIST' => "VOD file doesn't exist.", - 'WAIT_TIME_EXPIRED' => 'Stream start has timed out, failed to start.', +$rErrorCodes = [ + 'API_IP_NOT_ALLOWED' => 'IP is not allowed to access the API.', + 'ARCHIVE_DOESNT_EXIST' => 'Archive files are missing for this stream ID.', + 'ASN_BLOCKED' => 'ASN has been blocked.', + 'BANNED' => 'Line has been banned.', + 'BLOCKED_USER_AGENT' => 'User-agent has been blocked.', + 'DEVICE_NOT_ALLOWED' => 'MAG & Enigma devices are not allowed to access this.', + 'DISABLED' => 'Line has been disabled.', + 'DOWNLOAD_LIMIT_REACHED' => 'Reached the simultaneous download limit.', + 'E2_DEVICE_LOCK_FAILED' => 'Device lock checks failed.', + 'E2_DISABLED' => 'Device has been disabled.', + 'E2_NO_TOKEN' => 'No token has been specified.', + 'E2_TOKEN_DOESNT_MATCH' => "Token doesn't match records.", + 'E2_WATCHDOG_TIMEOUT' => 'Time limit reached.', + 'EMPTY_USER_AGENT' => 'Empty user-agents are disallowed.', + 'EPG_DISABLED' => 'EPG has been disabled.', + 'EPG_FILE_MISSING' => 'Cached EPG files are missing.', + 'EXPIRED' => 'Line has expired.', + 'FORCED_COUNTRY_INVALID' => 'Country does not match forced country.', + 'GENERATE_PLAYLIST_FAILED' => 'Playlist failed to generate.', + 'HLS_DISABLED' => 'HLS has been disabled.', + 'HOSTING_DETECT' => 'Hosting server has been detected.', + 'INVALID_API_PASSWORD' => 'API password is invalid.', + 'INVALID_CREDENTIALS' => 'Username or password is invalid.', + 'INVALID_HOST' => 'Domain name not recognised.', + 'INVALID_STREAM_ID' => "Stream ID doesn't exist.", + 'INVALID_TYPE_TOKEN' => "Tokens can't be used for this stream type.", + 'IP_BLOCKED' => 'IP has been blocked.', + 'IP_MISMATCH' => "Current IP doesn't match initial connection IP.", + 'ISP_BLOCKED' => 'ISP has been blocked.', + 'LB_TOKEN_INVALID' => 'AES Token cannot be decrypted.', + 'LEGACY_EPG_DISABLED' => 'Legacy epg.php access has been disabled.', + 'LEGACY_GET_DISABLED' => 'Legacy get.php access has been disabled.', + 'LEGACY_PANEL_API_DISABLED' => 'Legacy panel_api.php access has been disabled.', + 'LINE_CREATE_FAIL' => 'Line failed to insert into database.', + 'NO_CREDENTIALS' => 'No credentials have been specified.', + 'NO_TIMESTAMP' => 'No archive timestamp has been specified.', + 'NO_TOKEN_SPECIFIED' => 'No AES encrypted token has been specified.', + 'NOT_ENIGMA_DEVICE' => "Line isn't an enigma device.", + 'NOT_IN_ALLOWED_COUNTRY' => 'Not in allowed country list.', + 'NOT_IN_ALLOWED_IPS' => 'Not in allowed IP list.', + 'NOT_IN_ALLOWED_UAS' => 'Not in allowed user-agent list.', + 'NOT_IN_BOUQUET' => "Line doesn't have access to this stream ID.", + 'PLAYER_API_DISABLED' => 'Player API has been disabled.', + 'PROXY_DETECT' => 'Proxy has been detected.', + 'PROXY_NO_API_ACCESS' => "Can't access API's via proxy.", + 'RESTREAM_DETECT' => 'Restreaming has been detected.', + 'STALKER_CHANNEL_MISMATCH' => "Stream ID doesn't match stalker token.", + 'STALKER_DECRYPT_FAILED' => 'Failed to decrypt stalker token.', + 'STALKER_INVALID_KEY' => 'Invalid stalker key.', + 'STALKER_IP_MISMATCH' => "IP doesn't match stalker token.", + 'STALKER_KEY_EXPIRED' => 'Stalker token has expired.', + 'STREAM_OFFLINE' => 'Stream is currently offline.', + 'THUMBNAIL_DOESNT_EXIST' => "Thumbnail file doesn't exist.", + 'THUMBNAILS_NOT_ENABLED' => 'Thumbnail not enabled for this stream.', + 'TOKEN_ERROR' => 'AES token has incomplete data.', + 'TOKEN_EXPIRED' => 'AES token has expired.', + 'TS_DISABLED' => 'MPEG-TS has been disabled.', + 'USER_ALREADY_CONNECTED' => 'Line already connected on a different IP.', + 'USER_DISALLOW_EXT' => 'Extension is not in allowed list.', + 'VOD_DOESNT_EXIST' => "VOD file doesn't exist.", + 'WAIT_TIME_EXPIRED' => 'Stream start has timed out, failed to start.', - // ── Дополнительные коды (из stream/init.php) ────────────── - 'CACHE_INCOMPLETE' => 'Cache is being generated...', - 'SUBTITLE_DOESNT_EXIST' => "Subtitle file doesn't exist.", - 'NO_SERVERS_AVAILABLE' => 'No servers are currently available for this stream.', - 'PROXY_ACCESS_DENIED' => 'You cannot access this stream directly while proxy is enabled.', -); + // ── Дополнительные коды (из stream/init.php) ────────────── + 'CACHE_INCOMPLETE' => 'Cache is being generated...', + 'SUBTITLE_DOESNT_EXIST' => "Subtitle file doesn't exist.", + 'NO_SERVERS_AVAILABLE' => 'No servers are currently available for this stream.', + 'PROXY_ACCESS_DENIED' => 'You cannot access this stream directly while proxy is enabled.', +]; diff --git a/src/Core/Error/ErrorHandler.php b/src/Core/Error/ErrorHandler.php index 8c432a96..d54e0350 100644 --- a/src/Core/Error/ErrorHandler.php +++ b/src/Core/Error/ErrorHandler.php @@ -32,28 +32,28 @@ * @param bool $rKill Завершить выполнение после вывода (default: true) * @param int|null $rCode HTTP-код ответа (null = 404) */ -function generateError($rError, $rKill = true, $rCode = null) { - global $rErrorCodes; - global $rSettings; +function generateError(string $rError, bool $rKill = true, ?int $rCode = null) { + global $rErrorCodes; + global $rSettings; - if (isset($rSettings['debug_show_errors']) && $rSettings['debug_show_errors']) { - $rErrorDescription = (isset($rErrorCodes[$rError]) ? $rErrorCodes[$rError] : ''); - $rStyle = '*{-webkit-box-sizing:border-box;box-sizing:border-box}body{padding:0;margin:0}#notfound{position:relative;height:100vh}#notfound .notfound{position:absolute;left:50%;top:50%;-webkit-transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%);transform:translate(-50%,-50%)}.notfound{max-width:520px;width:100%;line-height:1.4;text-align:center}.notfound .notfound-404{position:relative;height:200px;margin:0 auto 20px;z-index:-1}.notfound .notfound-404 h1{font-family:Montserrat,sans-serif;font-size:236px;font-weight:200;margin:0;color:#211b19;text-transform:uppercase;position:absolute;left:50%;top:50%;-webkit-transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%);transform:translate(-50%,-50%)}.notfound .notfound-404 h2{font-family:Montserrat,sans-serif;font-size:28px;font-weight:400;text-transform:uppercase;color:#211b19;background:#fff;padding:10px 5px;margin:auto;display:inline-block;position:absolute;bottom:0;left:0;right:0}.notfound p{font-family:Montserrat,sans-serif;font-size:14px;font-weight:300;text-transform:uppercase}@media only screen and (max-width:767px){.notfound .notfound-404 h1{font-size:148px}}@media only screen and (max-width:480px){.notfound .notfound-404{height:148px;margin:0 auto 10px}.notfound .notfound-404 h1{font-size:86px}.notfound .notfound-404 h2{font-size:16px}}'; - echo 'XC_VM - Debug Mode

XC_VM

' . $rError . '


' . $rErrorDescription . '

'; + if (isset($rSettings['debug_show_errors']) && $rSettings['debug_show_errors']) { + $rErrorDescription = (isset($rErrorCodes[$rError]) ? $rErrorCodes[$rError] : ''); + $rStyle = '*{-webkit-box-sizing:border-box;box-sizing:border-box}body{padding:0;margin:0}#notfound{position:relative;height:100vh}#notfound .notfound{position:absolute;left:50%;top:50%;-webkit-transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%);transform:translate(-50%,-50%)}.notfound{max-width:520px;width:100%;line-height:1.4;text-align:center}.notfound .notfound-404{position:relative;height:200px;margin:0 auto 20px;z-index:-1}.notfound .notfound-404 h1{font-family:Montserrat,sans-serif;font-size:236px;font-weight:200;margin:0;color:#211b19;text-transform:uppercase;position:absolute;left:50%;top:50%;-webkit-transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%);transform:translate(-50%,-50%)}.notfound .notfound-404 h2{font-family:Montserrat,sans-serif;font-size:28px;font-weight:400;text-transform:uppercase;color:#211b19;background:#fff;padding:10px 5px;margin:auto;display:inline-block;position:absolute;bottom:0;left:0;right:0}.notfound p{font-family:Montserrat,sans-serif;font-size:14px;font-weight:300;text-transform:uppercase}@media only screen and (max-width:767px){.notfound .notfound-404 h1{font-size:148px}}@media only screen and (max-width:480px){.notfound .notfound-404{height:148px;margin:0 auto 10px}.notfound .notfound-404 h1{font-size:86px}.notfound .notfound-404 h2{font-size:16px}}'; + echo 'XC_VM - Debug Mode

XC_VM

' . $rError . '


' . $rErrorDescription . '

'; - if ($rKill) { - exit(); - } - } else { - if ($rKill) { - if (!$rCode) { - generate404(); - } else { - http_response_code($rCode); - exit(); - } - } - } + if ($rKill) { + exit(); + } + } else { + if ($rKill) { + if (!$rCode) { + generate404(); + } else { + http_response_code($rCode); + exit(); + } + } + } } /** @@ -61,11 +61,11 @@ function generateError($rError, $rKill = true, $rCode = null) { * * @param bool $rKill Завершить выполнение после вывода (default: true) */ -function generate404($rKill = true) { - echo '' . "\r\n" . '404 Not Found' . "\r\n" . '' . "\r\n" . '

404 Not Found

' . "\r\n" . '
nginx
' . "\r\n" . '' . "\r\n" . '' . "\r\n" . '' . "\r\n" . '' . "\r\n" . '' . "\r\n" . '' . "\r\n" . '' . "\r\n" . ''; - http_response_code(404); +function generate404(bool $rKill = true) { + echo '' . "\r\n" . '404 Not Found' . "\r\n" . '' . "\r\n" . '

404 Not Found

' . "\r\n" . '
nginx
' . "\r\n" . '' . "\r\n" . '' . "\r\n" . '' . "\r\n" . '' . "\r\n" . '' . "\r\n" . '' . "\r\n" . '' . "\r\n" . ''; + http_response_code(404); - if ($rKill) { - exit(); - } + if ($rKill) { + exit(); + } } diff --git a/src/Core/Events/AbstractEvent.php b/src/Core/Events/AbstractEvent.php index 43c99e02..2b804dc6 100644 --- a/src/Core/Events/AbstractEvent.php +++ b/src/Core/Events/AbstractEvent.php @@ -16,24 +16,23 @@ use XcVm\Core\Events\Contract\StoppableEventInterface; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ abstract class AbstractEvent implements StoppableEventInterface { + private bool $propagationStopped = false; - private bool $propagationStopped = false; + /** + * Whether a listener has stopped propagation of this event. + * + * @return bool True if propagation was stopped. + */ + public function isPropagationStopped(): bool { + return $this->propagationStopped; + } - /** - * Whether a listener has stopped propagation of this event. - * - * @return bool True if propagation was stopped. - */ - public function isPropagationStopped(): bool { - return $this->propagationStopped; - } - - /** - * Stop propagation so no further listeners receive this event. - * - * @return void - */ - public function stopPropagation(): void { - $this->propagationStopped = true; - } + /** + * Stop propagation so no further listeners receive this event. + * + * @return void + */ + public function stopPropagation(): void { + $this->propagationStopped = true; + } } diff --git a/src/Core/Events/Auth/UserAuthenticatedEvent.php b/src/Core/Events/Auth/UserAuthenticatedEvent.php index b0fea934..80a7aee3 100644 --- a/src/Core/Events/Auth/UserAuthenticatedEvent.php +++ b/src/Core/Events/Auth/UserAuthenticatedEvent.php @@ -11,16 +11,17 @@ namespace XcVm\Core\Events\Auth; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ final class UserAuthenticatedEvent { - /** - * @param int $userId Authenticated user id. - * @param string $username Authenticated username. - * @param string $role User role. - * @param float $authenticatedAt Unix timestamp (with microseconds) of authentication. - */ - public function __construct( - public readonly int $userId, - public readonly string $username, - public readonly string $role, - public readonly float $authenticatedAt, - ) {} + /** + * @param int $userId Authenticated user id. + * @param string $username Authenticated username. + * @param string $role User role. + * @param float $authenticatedAt Unix timestamp (with microseconds) of authentication. + */ + public function __construct( + public readonly int $userId, + public readonly string $username, + public readonly string $role, + public readonly float $authenticatedAt, + ) { + } } diff --git a/src/Core/Events/Auth/UserLoggedOutEvent.php b/src/Core/Events/Auth/UserLoggedOutEvent.php index 2a4f9d4a..c6f72987 100644 --- a/src/Core/Events/Auth/UserLoggedOutEvent.php +++ b/src/Core/Events/Auth/UserLoggedOutEvent.php @@ -11,14 +11,15 @@ namespace XcVm\Core\Events\Auth; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ final class UserLoggedOutEvent { - /** - * @param int $userId User that logged out. - * @param string $username Username that logged out. - * @param float $loggedOutAt Unix timestamp (with microseconds) of logout. - */ - public function __construct( - public readonly int $userId, - public readonly string $username, - public readonly float $loggedOutAt, - ) {} + /** + * @param int $userId User that logged out. + * @param string $username Username that logged out. + * @param float $loggedOutAt Unix timestamp (with microseconds) of logout. + */ + public function __construct( + public readonly int $userId, + public readonly string $username, + public readonly float $loggedOutAt, + ) { + } } diff --git a/src/Core/Events/Bouquet/BouquetDeletedEvent.php b/src/Core/Events/Bouquet/BouquetDeletedEvent.php index 81b607d2..21e76b05 100644 --- a/src/Core/Events/Bouquet/BouquetDeletedEvent.php +++ b/src/Core/Events/Bouquet/BouquetDeletedEvent.php @@ -14,10 +14,11 @@ namespace XcVm\Core\Events\Bouquet; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ final class BouquetDeletedEvent { - /** - * @param int $bouquetId Id of the bouquet that was deleted. - */ - public function __construct( - public readonly int $bouquetId, - ) {} + /** + * @param int $bouquetId Id of the bouquet that was deleted. + */ + public function __construct( + public readonly int $bouquetId, + ) { + } } diff --git a/src/Core/Events/Contract/StoppableEventInterface.php b/src/Core/Events/Contract/StoppableEventInterface.php index 26b26c1e..33ee707c 100644 --- a/src/Core/Events/Contract/StoppableEventInterface.php +++ b/src/Core/Events/Contract/StoppableEventInterface.php @@ -11,11 +11,10 @@ namespace XcVm\Core\Events\Contract; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface StoppableEventInterface { - - /** - * Whether propagation has been stopped by a previous listener. - * - * When true, EventDispatcher must not call any further listeners. - */ - public function isPropagationStopped(): bool; + /** + * Whether propagation has been stopped by a previous listener. + * + * When true, EventDispatcher must not call any further listeners. + */ + public function isPropagationStopped(): bool; } diff --git a/src/Core/Events/EventDispatcher.php b/src/Core/Events/EventDispatcher.php index 6f24439c..7b4486a7 100644 --- a/src/Core/Events/EventDispatcher.php +++ b/src/Core/Events/EventDispatcher.php @@ -60,128 +60,124 @@ use XcVm\Core\Events\Contract\StoppableEventInterface; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class EventDispatcher { + // ── Singleton management ────────────────────────────────────── + private static ?self $instance = null; - // ── Singleton management ────────────────────────────────────── + /** + * Return the active singleton instance, creating one on first call. + * + * In production: wired by bootstrap via setInstance(new EventDispatcher()). + * In tests: call setInstance() with a fresh instance per-test. + */ + public static function getInstance(): self { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } - private static ?self $instance = null; + /** + * Replace the active singleton (used by bootstrap and test setUp). + */ + public static function setInstance(self $instance): void { + self::$instance = $instance; + } - /** - * Return the active singleton instance, creating one on first call. - * - * In production: wired by bootstrap via setInstance(new EventDispatcher()). - * In tests: call setInstance() with a fresh instance per-test. - */ - public static function getInstance(): self { - if (self::$instance === null) { - self::$instance = new self(); - } - return self::$instance; - } + /** + * Null-out the singleton so the next getInstance() call creates a fresh one. + * + * Primarily for test tearDown to prevent listener state leaking between tests. + */ + public static function resetInstance(): void { + self::$instance = null; + } - /** - * Replace the active singleton (used by bootstrap and test setUp). - */ - public static function setInstance(self $instance): void { - self::$instance = $instance; - } + // ── Instance state ──────────────────────────────────────────── + private ListenerProvider $provider; - /** - * Null-out the singleton so the next getInstance() call creates a fresh one. - * - * Primarily for test tearDown to prevent listener state leaking between tests. - */ - public static function resetInstance(): void { - self::$instance = null; - } + /** + * Create a dispatcher backed by a fresh ListenerProvider. + */ + public function __construct() { + $this->provider = new ListenerProvider(); + } - // ── Instance state ──────────────────────────────────────────── + // ───────────────────────────────────────────────────────── + // PSR-14-style API — static methods delegate to getInstance() + // ───────────────────────────────────────────────────────── - private ListenerProvider $provider; + /** + * Dispatch a typed event object to all registered listeners. + * + * Listeners are called in priority order (highest first). + * Stops early if event implements StoppableEventInterface and propagation was stopped. + * + * @template T of object + * @param T $event + * @return T The same event, possibly mutated by listeners + */ + public static function dispatch(object $event): object { + $i = self::getInstance(); + foreach ($i->provider->getListenersForEvent($event) as $listener) { + if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) { + break; + } + $listener($event); + } + return $event; + } - /** - * Create a dispatcher backed by a fresh ListenerProvider. - */ - public function __construct() { - $this->provider = new ListenerProvider(); - } + /** + * Register a typed listener. + * + * @param class-string $eventClass Fully-qualified event class name + * @param callable $listener Receives the event object as sole argument + * @param int $priority Higher = called first (default 0) + */ + public static function listen(string $eventClass, callable $listener, int $priority = 0): void { + self::getInstance()->provider->addListener($eventClass, $listener, $priority); + } - // ───────────────────────────────────────────────────────── - // PSR-14-style API — static methods delegate to getInstance() - // ───────────────────────────────────────────────────────── + /** + * Remove a typed listener, or all listeners for an event class. + * + * @param class-string $eventClass + */ + public static function unlisten(string $eventClass, ?callable $listener = null): void { + self::getInstance()->provider->removeListener($eventClass, $listener); + } - /** - * Dispatch a typed event object to all registered listeners. - * - * Listeners are called in priority order (highest first). - * Stops early if event implements StoppableEventInterface and propagation was stopped. - * - * @template T of object - * @param T $event - * @return T The same event, possibly mutated by listeners - */ - public static function dispatch(object $event): object { - $i = self::getInstance(); - foreach ($i->provider->getListenersForEvent($event) as $listener) { - if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) { - break; - } - $listener($event); - } - return $event; - } + /** + * Check if any typed listeners are registered for an event class. + * + * @param class-string $eventClass + */ + public static function hasListeners(string $eventClass): bool { + return self::getInstance()->provider->hasListeners($eventClass); + } - /** - * Register a typed listener. - * - * @param class-string $eventClass Fully-qualified event class name - * @param callable $listener Receives the event object as sole argument - * @param int $priority Higher = called first (default 0) - */ - public static function listen(string $eventClass, callable $listener, int $priority = 0): void { - self::getInstance()->provider->addListener($eventClass, $listener, $priority); - } + // ───────────────────────────────────────────────────────── + // Utility + // ───────────────────────────────────────────────────────── - /** - * Remove a typed listener, or all listeners for an event class. - * - * @param class-string $eventClass - * @param callable|null $listener - */ - public static function unlisten(string $eventClass, ?callable $listener = null): void { - self::getInstance()->provider->removeListener($eventClass, $listener); - } + /** + * Clear all listeners — both typed and legacy (primarily for testing). + */ + public static function clear(): void { + self::getInstance()->provider->clear(); + } - /** - * Check if any typed listeners are registered for an event class. - * - * @param class-string $eventClass - */ - public static function hasListeners(string $eventClass): bool { - return self::getInstance()->provider->hasListeners($eventClass); - } + /** + * Return the underlying ListenerProvider (for introspection). + */ + public static function getProvider(): ListenerProvider { + return self::getInstance()->provider; + } - // ───────────────────────────────────────────────────────── - // Utility - // ───────────────────────────────────────────────────────── - - /** - * Clear all listeners — both typed and legacy (primarily for testing). - */ - public static function clear(): void { - self::getInstance()->provider->clear(); - } - - /** - * Return the underlying ListenerProvider (for introspection). - */ - public static function getProvider(): ListenerProvider { - return self::getInstance()->provider; - } - - /** - * Replace the ListenerProvider (for testing or custom provider injection). - */ - public static function setProvider(ListenerProvider $provider): void { - self::getInstance()->provider = $provider; - } + /** + * Replace the ListenerProvider (for testing or custom provider injection). + */ + public static function setProvider(ListenerProvider $provider): void { + self::getInstance()->provider = $provider; + } } diff --git a/src/Core/Events/ListenerProvider.php b/src/Core/Events/ListenerProvider.php index a7091fda..51c151b3 100644 --- a/src/Core/Events/ListenerProvider.php +++ b/src/Core/Events/ListenerProvider.php @@ -14,89 +14,84 @@ namespace XcVm\Core\Events; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class ListenerProvider { + /** @var array> */ + private array $listeners = []; - /** @var array> */ - private array $listeners = []; + /** + * Register a listener for an event class. + * + * @param string $eventClass Fully-qualified event class name + * @param int $priority Higher value = called first (default 0) + */ + public function addListener(string $eventClass, callable $listener, int $priority = 0): void { + $this->listeners[$eventClass][$priority][] = $listener; + } - /** - * Register a listener for an event class. - * - * @param string $eventClass Fully-qualified event class name - * @param callable $listener - * @param int $priority Higher value = called first (default 0) - */ - public function addListener(string $eventClass, callable $listener, int $priority = 0): void { - $this->listeners[$eventClass][$priority][] = $listener; - } + /** + * Remove all listeners for an event class, or a specific listener. + * + * @param callable|null $listener If null, removes all listeners for the event + */ + public function removeListener(string $eventClass, ?callable $listener = null): void { + if (!isset($this->listeners[$eventClass])) { + return; + } - /** - * Remove all listeners for an event class, or a specific listener. - * - * @param string $eventClass - * @param callable|null $listener If null, removes all listeners for the event - */ - public function removeListener(string $eventClass, ?callable $listener = null): void { - if (!isset($this->listeners[$eventClass])) { - return; - } + if ($listener === null) { + unset($this->listeners[$eventClass]); + return; + } - if ($listener === null) { - unset($this->listeners[$eventClass]); - return; - } + foreach ($this->listeners[$eventClass] as $priority => $group) { + $filtered = array_filter($group, fn($l) => $l !== $listener); + if (empty($filtered)) { + unset($this->listeners[$eventClass][$priority]); + } else { + $this->listeners[$eventClass][$priority] = array_values($filtered); + } + } + } - foreach ($this->listeners[$eventClass] as $priority => $group) { - $filtered = array_filter($group, fn($l) => $l !== $listener); - if (empty($filtered)) { - unset($this->listeners[$eventClass][$priority]); - } else { - $this->listeners[$eventClass][$priority] = array_values($filtered); - } - } - } + /** + * Yield listeners for the given event in priority order (highest first). + * + * @return iterable + */ + public function getListenersForEvent(object $event): iterable { + $class = $event::class; + if (!isset($this->listeners[$class])) { + return; + } - /** - * Yield listeners for the given event in priority order (highest first). - * - * @param object $event - * @return iterable - */ - public function getListenersForEvent(object $event): iterable { - $class = $event::class; - if (!isset($this->listeners[$class])) { - return; - } + $buckets = $this->listeners[$class]; + krsort($buckets); - $buckets = $this->listeners[$class]; - krsort($buckets); + foreach ($buckets as $group) { + yield from $group; + } + } - foreach ($buckets as $group) { - yield from $group; - } - } + /** + * Check whether any listeners are registered for an event class. + * + */ + public function hasListeners(string $eventClass): bool { + return !empty($this->listeners[$eventClass]); + } - /** - * Check whether any listeners are registered for an event class. - * - * @param string $eventClass - */ - public function hasListeners(string $eventClass): bool { - return !empty($this->listeners[$eventClass]); - } + /** + * Return all registered listeners (for debugging/introspection). + * + * @return array> + */ + public function all(): array { + return $this->listeners; + } - /** - * Return all registered listeners (for debugging/introspection). - * - * @return array> - */ - public function all(): array { - return $this->listeners; - } - - /** - * Remove all listeners (for testing). - */ - public function clear(): void { - $this->listeners = []; - } + /** + * Remove all listeners (for testing). + */ + public function clear(): void { + $this->listeners = []; + } } diff --git a/src/Core/Events/ListensTo.php b/src/Core/Events/ListensTo.php index b69f8005..db662d99 100644 --- a/src/Core/Events/ListensTo.php +++ b/src/Core/Events/ListensTo.php @@ -1,6 +1,5 @@ current, $this->previous)); - } + /** + * Keys whose values differ between previous and current settings. + * + * @return string[] + */ + public function changedKeys(): array { + return array_keys(array_diff_assoc($this->current, $this->previous)); + } } diff --git a/src/Core/Events/Stream/StreamStartedEvent.php b/src/Core/Events/Stream/StreamStartedEvent.php index 1a777540..f8a026b6 100644 --- a/src/Core/Events/Stream/StreamStartedEvent.php +++ b/src/Core/Events/Stream/StreamStartedEvent.php @@ -11,16 +11,17 @@ namespace XcVm\Core\Events\Stream; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ final class StreamStartedEvent { - /** - * @param int $streamId Stream that started. - * @param string $userId User the stream started for. - * @param string $protocol Delivery protocol. - * @param float $startedAt Unix timestamp (with microseconds) of start. - */ - public function __construct( - public readonly int $streamId, - public readonly string $userId, - public readonly string $protocol, - public readonly float $startedAt, - ) {} + /** + * @param int $streamId Stream that started. + * @param string $userId User the stream started for. + * @param string $protocol Delivery protocol. + * @param float $startedAt Unix timestamp (with microseconds) of start. + */ + public function __construct( + public readonly int $streamId, + public readonly string $userId, + public readonly string $protocol, + public readonly float $startedAt, + ) { + } } diff --git a/src/Core/Events/Stream/StreamStartingEvent.php b/src/Core/Events/Stream/StreamStartingEvent.php index eff2fe6f..c7f44fd0 100644 --- a/src/Core/Events/Stream/StreamStartingEvent.php +++ b/src/Core/Events/Stream/StreamStartingEvent.php @@ -16,39 +16,39 @@ use XcVm\Core\Events\AbstractEvent; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ final class StreamStartingEvent extends AbstractEvent { + private string $abortReason = ''; - private string $abortReason = ''; + /** + * @param int $streamId Stream being started. + * @param string $userId User requesting the stream. + * @param string $protocol Delivery protocol (e.g. hls, mpegts). + * @param array $params Additional request parameters. + */ + public function __construct( + public readonly int $streamId, + public readonly string $userId, + public readonly string $protocol, + public readonly array $params, + ) { + } - /** - * @param int $streamId Stream being started. - * @param string $userId User requesting the stream. - * @param string $protocol Delivery protocol (e.g. hls, mpegts). - * @param array $params Additional request parameters. - */ - public function __construct( - public readonly int $streamId, - public readonly string $userId, - public readonly string $protocol, - public readonly array $params, - ) {} + /** + * Abort the stream start and stop event propagation. + * + * @param string $reason Human-readable abort reason. + * @return void + */ + public function abort(string $reason): void { + $this->abortReason = $reason; + $this->stopPropagation(); + } - /** - * Abort the stream start and stop event propagation. - * - * @param string $reason Human-readable abort reason. - * @return void - */ - public function abort(string $reason): void { - $this->abortReason = $reason; - $this->stopPropagation(); - } - - /** - * Reason supplied to abort(), or '' if not aborted. - * - * @return string - */ - public function getAbortReason(): string { - return $this->abortReason; - } + /** + * Reason supplied to abort(), or '' if not aborted. + * + * @return string + */ + public function getAbortReason(): string { + return $this->abortReason; + } } diff --git a/src/Core/Events/Stream/StreamStoppedEvent.php b/src/Core/Events/Stream/StreamStoppedEvent.php index 721dcaf4..c96326bc 100644 --- a/src/Core/Events/Stream/StreamStoppedEvent.php +++ b/src/Core/Events/Stream/StreamStoppedEvent.php @@ -11,16 +11,17 @@ namespace XcVm\Core\Events\Stream; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ final class StreamStoppedEvent { - /** - * @param int $streamId Stream that stopped. - * @param string $userId User the stream belonged to. - * @param float $stoppedAt Unix timestamp (with microseconds) of stop. - * @param string $reason Reason the stream stopped. - */ - public function __construct( - public readonly int $streamId, - public readonly string $userId, - public readonly float $stoppedAt, - public readonly string $reason, - ) {} + /** + * @param int $streamId Stream that stopped. + * @param string $userId User the stream belonged to. + * @param float $stoppedAt Unix timestamp (with microseconds) of stop. + * @param string $reason Reason the stream stopped. + */ + public function __construct( + public readonly int $streamId, + public readonly string $userId, + public readonly float $stoppedAt, + public readonly string $reason, + ) { + } } diff --git a/src/Core/Events/Stream/StreamsDeletedEvent.php b/src/Core/Events/Stream/StreamsDeletedEvent.php index 13525d0e..ca359e87 100644 --- a/src/Core/Events/Stream/StreamsDeletedEvent.php +++ b/src/Core/Events/Stream/StreamsDeletedEvent.php @@ -14,10 +14,11 @@ namespace XcVm\Core\Events\Stream; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ final class StreamsDeletedEvent { - /** - * @param int[] $streamIds Ids of the streams that were deleted. - */ - public function __construct( - public readonly array $streamIds, - ) {} + /** + * @param int[] $streamIds Ids of the streams that were deleted. + */ + public function __construct( + public readonly array $streamIds, + ) { + } } diff --git a/src/Core/Events/Vod/VodImportedEvent.php b/src/Core/Events/Vod/VodImportedEvent.php index b39565cc..4ab3d206 100644 --- a/src/Core/Events/Vod/VodImportedEvent.php +++ b/src/Core/Events/Vod/VodImportedEvent.php @@ -16,14 +16,15 @@ namespace XcVm\Core\Events\Vod; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ final class VodImportedEvent { - /** - * @param int $streamId Id of the created/updated VOD stream. - * @param string $sourcePath File path the item was imported from (server prefix stripped). - * @param int $type VOD type (1 = movie). - */ - public function __construct( - public readonly int $streamId, - public readonly string $sourcePath, - public readonly int $type = 1, - ) {} + /** + * @param int $streamId Id of the created/updated VOD stream. + * @param string $sourcePath File path the item was imported from (server prefix stripped). + * @param int $type VOD type (1 = movie). + */ + public function __construct( + public readonly int $streamId, + public readonly string $sourcePath, + public readonly int $type = 1, + ) { + } } diff --git a/src/Core/Exception/Container/CircularDependencyException.php b/src/Core/Exception/Container/CircularDependencyException.php index 07bcea59..42d5482f 100644 --- a/src/Core/Exception/Container/CircularDependencyException.php +++ b/src/Core/Exception/Container/CircularDependencyException.php @@ -12,4 +12,5 @@ namespace XcVm\Core\Exception\Container; * @copyright 2025-2026 Vateron Media * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -class CircularDependencyException extends ContainerException {} +class CircularDependencyException extends ContainerException { +} diff --git a/src/Core/Exception/Container/ContainerException.php b/src/Core/Exception/Container/ContainerException.php index 6b895729..6128624e 100644 --- a/src/Core/Exception/Container/ContainerException.php +++ b/src/Core/Exception/Container/ContainerException.php @@ -16,4 +16,5 @@ use XcVm\Core\Exception\XcVmException; * @copyright 2025-2026 Vateron Media * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -class ContainerException extends XcVmException implements ContainerExceptionInterface {} +class ContainerException extends XcVmException implements ContainerExceptionInterface { +} diff --git a/src/Core/Exception/Container/ServiceCreationException.php b/src/Core/Exception/Container/ServiceCreationException.php index 902a5a52..ada419a8 100644 --- a/src/Core/Exception/Container/ServiceCreationException.php +++ b/src/Core/Exception/Container/ServiceCreationException.php @@ -13,4 +13,5 @@ namespace XcVm\Core\Exception\Container; * @copyright 2025-2026 Vateron Media * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -class ServiceCreationException extends ContainerException {} +class ServiceCreationException extends ContainerException { +} diff --git a/src/Core/Exception/Module/ModuleCycleException.php b/src/Core/Exception/Module/ModuleCycleException.php index 5d028c1d..c1ad6033 100644 --- a/src/Core/Exception/Module/ModuleCycleException.php +++ b/src/Core/Exception/Module/ModuleCycleException.php @@ -12,4 +12,5 @@ namespace XcVm\Core\Exception\Module; * @copyright 2025-2026 Vateron Media * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -class ModuleCycleException extends ModuleException {} +class ModuleCycleException extends ModuleException { +} diff --git a/src/Core/Exception/Module/ModuleException.php b/src/Core/Exception/Module/ModuleException.php index e4cc926e..020d47f6 100644 --- a/src/Core/Exception/Module/ModuleException.php +++ b/src/Core/Exception/Module/ModuleException.php @@ -12,4 +12,5 @@ use XcVm\Core\Exception\XcVmException; * @copyright 2025-2026 Vateron Media * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -class ModuleException extends XcVmException {} +class ModuleException extends XcVmException { +} diff --git a/src/Core/Exception/Module/ModuleLoadException.php b/src/Core/Exception/Module/ModuleLoadException.php index fc867889..5fa7fe2b 100644 --- a/src/Core/Exception/Module/ModuleLoadException.php +++ b/src/Core/Exception/Module/ModuleLoadException.php @@ -10,4 +10,5 @@ namespace XcVm\Core\Exception\Module; * @copyright 2025-2026 Vateron Media * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -class ModuleLoadException extends ModuleException {} +class ModuleLoadException extends ModuleException { +} diff --git a/src/Core/Exception/Module/ModuleManifestException.php b/src/Core/Exception/Module/ModuleManifestException.php index 33cc55a7..a4d666f4 100644 --- a/src/Core/Exception/Module/ModuleManifestException.php +++ b/src/Core/Exception/Module/ModuleManifestException.php @@ -10,4 +10,5 @@ namespace XcVm\Core\Exception\Module; * @copyright 2025-2026 Vateron Media * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -class ModuleManifestException extends ModuleException {} +class ModuleManifestException extends ModuleException { +} diff --git a/src/Core/Exception/Module/ModuleNotFoundException.php b/src/Core/Exception/Module/ModuleNotFoundException.php index 629cb11f..e20688a6 100644 --- a/src/Core/Exception/Module/ModuleNotFoundException.php +++ b/src/Core/Exception/Module/ModuleNotFoundException.php @@ -10,4 +10,5 @@ namespace XcVm\Core\Exception\Module; * @copyright 2025-2026 Vateron Media * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -class ModuleNotFoundException extends ModuleException {} +class ModuleNotFoundException extends ModuleException { +} diff --git a/src/Core/Exception/XcVmException.php b/src/Core/Exception/XcVmException.php index 58096dfd..9412fa66 100644 --- a/src/Core/Exception/XcVmException.php +++ b/src/Core/Exception/XcVmException.php @@ -18,4 +18,5 @@ namespace XcVm\Core\Exception; * @copyright 2025-2026 Vateron Media * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ -class XcVmException extends \RuntimeException {} +class XcVmException extends \RuntimeException { +} diff --git a/src/Core/GeoIP/AsnCatalogSync.php b/src/Core/GeoIP/AsnCatalogSync.php index 6aac03ba..b8cf584e 100644 --- a/src/Core/GeoIP/AsnCatalogSync.php +++ b/src/Core/GeoIP/AsnCatalogSync.php @@ -103,25 +103,25 @@ class AsnCatalogSync { public static function sync(): array { $rPath = self::path(); if (!is_file($rPath)) { - return array('upserted' => 0, 'removed' => 0, 'skipped' => 'no-file'); + return ['upserted' => 0, 'removed' => 0, 'skipped' => 'no-file']; } $rRaw = @file_get_contents($rPath); if ($rRaw === false || $rRaw === '') { - return array('upserted' => 0, 'removed' => 0, 'skipped' => 'empty'); + return ['upserted' => 0, 'removed' => 0, 'skipped' => 'empty']; } // Transparently gunzip (the release ships .gz; a plain .json also works). if (substr($rRaw, 0, 2) === "\x1f\x8b") { $rRaw = @gzdecode($rRaw); if ($rRaw === false) { - return array('upserted' => 0, 'removed' => 0, 'skipped' => 'bad-gzip'); + return ['upserted' => 0, 'removed' => 0, 'skipped' => 'bad-gzip']; } } $rRecords = json_decode($rRaw, true); unset($rRaw); if (!is_array($rRecords) || count($rRecords) === 0) { - return array('upserted' => 0, 'removed' => 0, 'skipped' => 'bad-json'); + return ['upserted' => 0, 'removed' => 0, 'skipped' => 'bad-json']; } $db = self::db(); @@ -130,8 +130,8 @@ class AsnCatalogSync { $db->query('CREATE TEMPORARY TABLE `tmp_asns` (`asn` INT PRIMARY KEY) ENGINE=MEMORY;'); $rUpserted = 0; - $rUpsertBatch = array(); - $rTmpBatch = array(); + $rUpsertBatch = []; + $rTmpBatch = []; foreach ($rRecords as $rRow) { $rAsn = isset($rRow['asn']) ? intval($rRow['asn']) : 0; @@ -140,21 +140,21 @@ class AsnCatalogSync { } $rDomain = (isset($rRow['domain']) && $rRow['domain'] !== '') ? (string) $rRow['domain'] : null; - $rUpsertBatch[] = array( + $rUpsertBatch[] = [ $rAsn, isset($rRow['isp']) ? (string) $rRow['isp'] : null, $rDomain, isset($rRow['country']) ? (string) $rRow['country'] : null, isset($rRow['num_ips']) ? intval($rRow['num_ips']) : 0, isset($rRow['type']) ? (string) $rRow['type'] : null, - ); + ]; $rTmpBatch[] = $rAsn; if (count($rUpsertBatch) >= self::BATCH) { $rUpserted += self::flushUpsert($db, $rUpsertBatch); self::flushTmp($db, $rTmpBatch); - $rUpsertBatch = array(); - $rTmpBatch = array(); + $rUpsertBatch = []; + $rTmpBatch = []; } } if (count($rUpsertBatch) > 0) { @@ -169,20 +169,19 @@ class AsnCatalogSync { $db->query('DELETE `b` FROM `blocked_asns` `b` LEFT JOIN `tmp_asns` `t` ON `b`.`asn` = `t`.`asn` WHERE `t`.`asn` IS NULL AND `b`.`blocked` = 0;'); $db->query('DROP TEMPORARY TABLE IF EXISTS `tmp_asns`;'); - return array('upserted' => $rUpserted, 'removed' => $rRemoved); + return ['upserted' => $rUpserted, 'removed' => $rRemoved]; } /** * Multi-row upsert of one batch. `blocked` is intentionally absent from the * column list, so it is never overwritten (new rows take its default 0). * - * @param object $db * @param array> $rBatch Rows [asn, isp, domain, country, num_ips, type]. * @return int Rows sent. */ - private static function flushUpsert($db, array $rBatch): int { + private static function flushUpsert(object $db, array $rBatch): int { $rPlaceholders = implode(',', array_fill(0, count($rBatch), '(?,?,?,?,?,?)')); - $rParams = array(); + $rParams = []; foreach ($rBatch as $rCols) { foreach ($rCols as $rVal) { $rParams[] = $rVal; @@ -198,10 +197,9 @@ class AsnCatalogSync { /** * Batch-insert ASNs into the temp snapshot table used by the prune step. * - * @param object $db * @param array $rAsns */ - private static function flushTmp($db, array $rAsns): void { + private static function flushTmp(object $db, array $rAsns): void { if (count($rAsns) === 0) { return; } diff --git a/src/Core/GeoIP/GeoIPService.php b/src/Core/GeoIP/GeoIPService.php index ebdfcd16..f48d57f7 100644 --- a/src/Core/GeoIP/GeoIPService.php +++ b/src/Core/GeoIP/GeoIPService.php @@ -17,16 +17,14 @@ use XcVm\Core\Util\GeoIP; */ class GeoIPService { - /** * Получить GeoIP-информацию по IP-адресу (GeoLite2). * * Результат кэшируется в файл CONS_TMP_PATH/md5(ip)_geo2. * - * @param string $rIP * @return array|false */ - public static function getIPInfo($rIP) { + public static function getIPInfo(string $rIP) { if (!empty($rIP)) { if (!file_exists(CONS_TMP_PATH . md5($rIP) . '_geo2')) { $rGeoIP = new \MaxMind\Db\Reader(GEOLITE2_BIN); @@ -47,10 +45,9 @@ class GeoIPService { * * Результат кэшируется в файл CONS_TMP_PATH/md5(ip)_isp. * - * @param string $rIP * @return array|false */ - public static function getISP($rIP) { + public static function getISP(string $rIP) { if (!empty($rIP)) { $rResponse = (file_exists(CONS_TMP_PATH . md5($rIP) . '_isp') ? json_decode(file_get_contents(CONS_TMP_PATH . md5($rIP) . '_isp'), true) : null); if (!is_array($rResponse)) { @@ -73,7 +70,7 @@ class GeoIPService { * @param string $rIP IP address * @return array|null Matching CIDR data or null */ - public static function matchCIDR($rASN, $rIP) { + public static function matchCIDR(string $rASN, string $rIP) { if (file_exists(CIDR_TMP_PATH . $rASN)) { $rCIDRs = json_decode(file_get_contents(CIDR_TMP_PATH . $rASN), true); foreach ($rCIDRs as $rData) { diff --git a/src/Core/GeoIP/GeoLiteReleaseUpdater.php b/src/Core/GeoIP/GeoLiteReleaseUpdater.php index f70d0b05..e5370c21 100644 --- a/src/Core/GeoIP/GeoLiteReleaseUpdater.php +++ b/src/Core/GeoIP/GeoLiteReleaseUpdater.php @@ -18,125 +18,123 @@ use XcVm\Core\Updates\UpdateChannels; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class GeoLiteReleaseUpdater { + private const DIR = '/home/xc_vm/bin/maxmind/'; + private const VERSION_FILE = self::DIR . 'version.json'; + private const GEOLITE_FILES = ['GeoLite2-City.mmdb', 'GeoLite2-Country.mmdb']; + private const ISP_FILE = 'GeoIP2-ISP.mmdb'; - private const DIR = '/home/xc_vm/bin/maxmind/'; - private const VERSION_FILE = self::DIR . 'version.json'; - private const GEOLITE_FILES = array('GeoLite2-City.mmdb', 'GeoLite2-Country.mmdb'); - private const ISP_FILE = 'GeoIP2-ISP.mmdb'; + private GitHubReleases $rRepo; - private GitHubReleases $rRepo; + public function __construct(?GitHubReleases $rRepo = null) { + $this->rRepo = $rRepo ?? new GitHubReleases(GIT_OWNER, GIT_REPO_UPDATE, UpdateChannels::forRepo(GIT_REPO_UPDATE)); + } - public function __construct(?GitHubReleases $rRepo = null) { - $this->rRepo = $rRepo ?? new GitHubReleases(GIT_OWNER, GIT_REPO_UPDATE, UpdateChannels::forRepo(GIT_REPO_UPDATE)); - } + /** + * Download the GeoLite2 databases from the latest release and record the + * version. Returns true when any file failed to download. + */ + public function updateGeoLite(bool $rForce): bool { + $rVersion = $this->rRepo->getReleases()[0] ?? null; + if ($rVersion === null) { + echo "[ERROR] GeoLite2: release metadata unavailable\n"; + return true; + } - /** - * Download the GeoLite2 databases from the latest release and record the - * version. Returns true when any file failed to download. - */ - public function updateGeoLite(bool $rForce): bool { - $rVersion = $this->rRepo->getReleases()[0] ?? null; - if ($rVersion === null) { - echo "[ERROR] GeoLite2: release metadata unavailable\n"; - return true; - } + $rHadError = false; + foreach (self::GEOLITE_FILES as $rFile) { + if ($this->downloadReleaseFile($this->assetSpec($rVersion, $rFile), $rForce) === null) { + $rHadError = true; + } + } - $rHadError = false; - foreach (self::GEOLITE_FILES as $rFile) { - if ($this->downloadReleaseFile($this->assetSpec($rVersion, $rFile), $rForce) === null) { - $rHadError = true; - } - } + $this->recordVersion('geolite2_version', $rVersion); + return $rHadError; + } - $this->recordVersion('geolite2_version', $rVersion); - return $rHadError; - } + /** + * Sync the free GeoIP2-ISP database from the latest release and record its + * version — the exact same path as {@see updateGeoLite()}. + */ + public function updateIsp(bool $rForce): void { + $rVersion = $this->rRepo->getReleases()[0] ?? null; + if ($rVersion === null) { + return; + } - /** - * Sync the free GeoIP2-ISP database from the latest release and record its - * version — the exact same path as {@see updateGeoLite()}. - */ - public function updateIsp(bool $rForce): void { - $rVersion = $this->rRepo->getReleases()[0] ?? null; - if ($rVersion === null) { - return; - } + if ($this->downloadReleaseFile($this->assetSpec($rVersion, self::ISP_FILE), $rForce) !== null) { + $this->recordVersion('geoisp_version', $rVersion); + } + } - if ($this->downloadReleaseFile($this->assetSpec($rVersion, self::ISP_FILE), $rForce) !== null) { - $this->recordVersion('geoisp_version', $rVersion); - } - } + /** + * Build the download spec for one maxmind asset in a given release. + * + * @return array{fileurl: string, path: string, md5: ?string} + */ + private function assetSpec(string $rVersion, string $rFile): array { + return [ + 'fileurl' => $this->rRepo->assetUrl($rVersion, $rFile), + 'path' => self::DIR . $rFile, + 'md5' => $this->rRepo->getAssetHash($rVersion, $rFile), + ]; + } - /** - * Build the download spec for one maxmind asset in a given release. - * - * @return array{fileurl: string, path: string, md5: ?string} - */ - private function assetSpec(string $rVersion, string $rFile): array { - return array( - 'fileurl' => $this->rRepo->assetUrl($rVersion, $rFile), - 'path' => self::DIR . $rFile, - 'md5' => $this->rRepo->getAssetHash($rVersion, $rFile), - ); - } + /** + * Merge a single key into maxmind/version.json. + * + */ + protected function recordVersion(string $rKey, mixed $rVersion): void { + $rData = json_decode(@file_get_contents(self::VERSION_FILE), true) ?: []; + $rData[$rKey] = $rVersion; + file_put_contents(self::VERSION_FILE, json_encode($rData, JSON_PRETTY_PRINT)); + } - /** - * Merge a single key into maxmind/version.json. - * - * @param mixed $rVersion - */ - protected function recordVersion(string $rKey, $rVersion): void { - $rData = json_decode(@file_get_contents(self::VERSION_FILE), true) ?: array(); - $rData[$rKey] = $rVersion; - file_put_contents(self::VERSION_FILE, json_encode($rData, JSON_PRETTY_PRINT)); - } + /** + * Download one release asset: md5-gated skip, create the target dir if + * missing, and ALWAYS save a successfully downloaded file (the checksum only + * sets the status line — the release `hashes.md5` can time out over SSL or + * lag a release, and GeoIP data is non-critical). + * + * @param array{fileurl: string, path: string, md5: ?string} $rFile + * @return bool|null true = downloaded, false = skipped (up to date), null = error. + */ + protected function downloadReleaseFile(array $rFile, bool $rForce): ?bool { + if (!$rForce && is_file($rFile['path']) && !empty($rFile['md5']) && md5_file($rFile['path']) === $rFile['md5']) { + echo '[SKIP] ' . $rFile['path'] . ': already up to date' . "\n"; + return false; + } - /** - * Download one release asset: md5-gated skip, create the target dir if - * missing, and ALWAYS save a successfully downloaded file (the checksum only - * sets the status line — the release `hashes.md5` can time out over SSL or - * lag a release, and GeoIP data is non-critical). - * - * @param array{fileurl: string, path: string, md5: ?string} $rFile - * @return bool|null true = downloaded, false = skipped (up to date), null = error. - */ - protected function downloadReleaseFile(array $rFile, bool $rForce): ?bool { - if (!$rForce && is_file($rFile['path']) && !empty($rFile['md5']) && md5_file($rFile['path']) === $rFile['md5']) { - echo '[SKIP] ' . $rFile['path'] . ': already up to date' . "\n"; - return false; - } + $rFolderPath = pathinfo($rFile['path'])['dirname'] . '/'; + if (!file_exists($rFolderPath)) { + shell_exec('sudo mkdir -p "' . $rFolderPath . '"'); + } - $rFolderPath = pathinfo($rFile['path'])['dirname'] . '/'; - if (!file_exists($rFolderPath)) { - shell_exec('sudo mkdir -p "' . $rFolderPath . '"'); - } + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $rFile['fileurl']); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30); + curl_setopt($ch, CURLOPT_TIMEOUT, 300); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'); + $rData = curl_exec($ch); + curl_close($ch); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $rFile['fileurl']); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30); - curl_setopt($ch, CURLOPT_TIMEOUT, 300); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'); - $rData = curl_exec($ch); - curl_close($ch); + if ($rData === false || $rData === '') { + echo '[ERROR] ' . $rFile['path'] . ': download failed' . "\n"; + return null; + } - if ($rData === false || $rData === '') { - echo '[ERROR] ' . $rFile['path'] . ': download failed' . "\n"; - return null; - } + if (empty($rFile['md5'])) { + echo '[WARN] ' . $rFile['path'] . ': saved without checksum (hash unavailable)' . "\n"; + } elseif ($rFile['md5'] === md5($rData)) { + echo '[OK] ' . $rFile['path'] . ': updated' . "\n"; + } else { + echo '[WARN] ' . $rFile['path'] . ': checksum mismatch — saved anyway' . "\n"; + } - if (empty($rFile['md5'])) { - echo '[WARN] ' . $rFile['path'] . ': saved without checksum (hash unavailable)' . "\n"; - } elseif ($rFile['md5'] === md5($rData)) { - echo '[OK] ' . $rFile['path'] . ': updated' . "\n"; - } else { - echo '[WARN] ' . $rFile['path'] . ': checksum mismatch — saved anyway' . "\n"; - } - - file_put_contents($rFile['path'], $rData); - chown($rFile['path'], 'xc_vm'); - chmod($rFile['path'], 0750); - return true; - } + file_put_contents($rFile['path'], $rData); + chown($rFile['path'], 'xc_vm'); + chmod($rFile['path'], 0750); + return true; + } } diff --git a/src/Core/GeoIP/MaxMindUpdater.php b/src/Core/GeoIP/MaxMindUpdater.php index 3d7e6f14..367f1227 100644 --- a/src/Core/GeoIP/MaxMindUpdater.php +++ b/src/Core/GeoIP/MaxMindUpdater.php @@ -20,14 +20,15 @@ use XcVm\Core\Util\GeoIP; * Implemented for: https://github.com/Vateron-Media/XC_VM/issues/102 */ class MaxMindUpdater { - private const DOWNLOAD_URL = 'https://download.maxmind.com/geoip/databases/%s/download?suffix=tar.gz'; private const MAXMIND_DIR = '/home/xc_vm/bin/maxmind/'; private const VERSION_FILE = '/home/xc_vm/bin/maxmind/version.json'; private const TIMEOUT = 300; private string $accountId; + private string $licenseKey; + /** @var string[] */ private array $editions; @@ -244,4 +245,4 @@ class MaxMindUpdater { 'GeoIP2-Anonymous-IP' => 'GeoIP2-Anonymous-IP (paid)', ]; } -} \ No newline at end of file +} diff --git a/src/Core/Http/ApiClient.php b/src/Core/Http/ApiClient.php index 3c1083e5..f47bdaf2 100644 --- a/src/Core/Http/ApiClient.php +++ b/src/Core/Http/ApiClient.php @@ -23,7 +23,7 @@ class ApiClient { * @param int $rTimeout Connect/read timeout in seconds. * @return string|bool Response body, or false on failure. */ - public static function request($rData, $rTimeout = 5) { + public static function request(array $rData, int $rTimeout = 5) { ini_set('default_socket_timeout', $rTimeout); $rAPI = 'http://127.0.0.1:' . intval(ServerRepository::getAll()[SERVER_ID]['http_broadcast_port']) . '/admin/api'; @@ -51,7 +51,7 @@ class ApiClient { * @param int $rTimeout Connect/read timeout in seconds. * @return string|null Response body, or null if the server is offline/unknown. */ - public static function systemRequest($rServerID, $rData, $rTimeout = 5) { + public static function systemRequest(int $rServerID, array $rData, int $rTimeout = 5) { ini_set('default_socket_timeout', $rTimeout); global $rServers, $rSettings; if (!is_array($rServers) || !isset($rServers[$rServerID])) { @@ -83,19 +83,19 @@ class ApiClient { * @param array $rData Request payload sent to each server. * @return array ['result' => true]. */ - public static function asyncRequest($rServerIDs, $rData) { - $rURLs = array(); + public static function asyncRequest(array $rServerIDs, array $rData) { + $rURLs = []; global $rServers; foreach ($rServerIDs as $rServerID) { if (!$rServers[$rServerID]['server_online']) { } else { - $rURLs[$rServerID] = array('url' => $rServers[$rServerID]['api_url'], 'postdata' => $rData); + $rURLs[$rServerID] = ['url' => $rServers[$rServerID]['api_url'], 'postdata' => $rData]; } } CurlClient::getMultiCURL($rURLs); - return array('result' => true); + return ['result' => true]; } /** @@ -106,8 +106,8 @@ class ApiClient { * @param string[]|null $rAllowed Allowed file extensions filter. * @return array|null Decoded directory listing, or null on failure. */ - public static function scanRecursive($rServerID, $rDirectory, $rAllowed = null) { - return json_decode(self::systemRequest($rServerID, array('action' => 'scandir_recursive', 'dir' => $rDirectory, 'allowed' => implode('|', $rAllowed))), true); + public static function scanRecursive(int $rServerID, string $rDirectory, ?array $rAllowed = null) { + return json_decode(self::systemRequest($rServerID, ['action' => 'scandir_recursive', 'dir' => $rDirectory, 'allowed' => implode('|', $rAllowed)]), true); } /** @@ -118,7 +118,7 @@ class ApiClient { * @param string[]|null $rAllowed Allowed file extensions filter. * @return array|null Decoded directory listing, or null on failure. */ - public static function listDir($rServerID, $rDirectory, $rAllowed = null) { - return json_decode(self::systemRequest($rServerID, array('action' => 'scandir', 'dir' => $rDirectory, 'allowed' => implode('|', $rAllowed))), true); + public static function listDir(int $rServerID, string $rDirectory, ?array $rAllowed = null) { + return json_decode(self::systemRequest($rServerID, ['action' => 'scandir', 'dir' => $rDirectory, 'allowed' => implode('|', $rAllowed)]), true); } } diff --git a/src/Core/Http/CurlClient.php b/src/Core/Http/CurlClient.php index 1aefdeb3..b42f22d6 100644 --- a/src/Core/Http/CurlClient.php +++ b/src/Core/Http/CurlClient.php @@ -21,15 +21,15 @@ class CurlClient { * @param int $rTimeout Per-request timeout in seconds. * @return array Map of serverId => response (false for offline servers). */ - public static function getMultiCURL($rURLs, $callback = null, $rTimeout = 5) { + public static function getMultiCURL(array $rURLs, ?callable $callback = null, int $rTimeout = 5) { global $rServers; if (empty($rURLs)) { - return array(); + return []; } - $rOffline = array(); - $rCurl = array(); - $rResults = array(); + $rOffline = []; + $rCurl = []; + $rResults = []; $rMulti = curl_multi_init(); foreach ($rURLs as $rKey => $rValue) { @@ -95,7 +95,7 @@ class CurlClient { * @param bool $rWait Return the response body (true) or fire-and-forget (false). * @return string|bool Response body, or the curl result. */ - public static function getURL($rURL, $rWait = true) { + public static function getURL(string $rURL, bool $rWait = true) { $ch = curl_init(); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3); curl_setopt($ch, CURLOPT_TIMEOUT, 3); @@ -115,7 +115,7 @@ class CurlClient { * @param array $rPostData Optional POST fields. * @return string|bool Response body, or false if the server is offline/unreachable. */ - public static function serverRequest($rServerID, $rURL, $rPostData = array()) { + public static function serverRequest(int $rServerID, string $rURL, array $rPostData = []) { global $rServers; if (!(is_array($rServers) && isset($rServers[$rServerID]) && $rServers[$rServerID]['server_online'])) { return false; diff --git a/src/Core/Http/Pipeline/StreamContext.php b/src/Core/Http/Pipeline/StreamContext.php index 8025b7be..1630573e 100644 --- a/src/Core/Http/Pipeline/StreamContext.php +++ b/src/Core/Http/Pipeline/StreamContext.php @@ -15,101 +15,103 @@ namespace XcVm\Core\Http\Pipeline; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ final class StreamContext { + private bool $aborted = false; - private bool $aborted = false; - private string $abortReason = ''; - private int $abortCode = 0; + private string $abortReason = ''; - /** @var array Arbitrary data bag for middleware communication */ - private array $attributes = []; + private int $abortCode = 0; - /** - * @param int $streamId Stream being processed. - * @param string $userId User requesting the stream. - * @param string $protocol Delivery protocol. - * @param array $params Request parameters. - */ - public function __construct( - public readonly int $streamId, - public readonly string $userId, - public readonly string $protocol, - public readonly array $params, - ) {} + /** @var array Arbitrary data bag for middleware communication */ + private array $attributes = []; - // ───────────────────────────────────────────────────────── - // Abort control - // ───────────────────────────────────────────────────────── + /** + * @param int $streamId Stream being processed. + * @param string $userId User requesting the stream. + * @param string $protocol Delivery protocol. + * @param array $params Request parameters. + */ + public function __construct( + public readonly int $streamId, + public readonly string $userId, + public readonly string $protocol, + public readonly array $params, + ) { + } - /** - * Abort pipeline execution with a reason and optional HTTP-style code. - * - * Once aborted, StreamPipeline will not call further middleware. - * - * @param string $reason Human-readable reason (logged / returned to client) - * @param int $code Application-level error code (0 = unspecified) - */ - public function abort(string $reason, int $code = 0): void { - $this->aborted = true; - $this->abortReason = $reason; - $this->abortCode = $code; - } + // ───────────────────────────────────────────────────────── + // Abort control + // ───────────────────────────────────────────────────────── - /** - * Whether the pipeline has been aborted. - * - * @return bool - */ - public function isAborted(): bool { - return $this->aborted; - } + /** + * Abort pipeline execution with a reason and optional HTTP-style code. + * + * Once aborted, StreamPipeline will not call further middleware. + * + * @param string $reason Human-readable reason (logged / returned to client) + * @param int $code Application-level error code (0 = unspecified) + */ + public function abort(string $reason, int $code = 0): void { + $this->aborted = true; + $this->abortReason = $reason; + $this->abortCode = $code; + } - /** - * Reason passed to abort(), or '' if not aborted. - * - * @return string - */ - public function getAbortReason(): string { - return $this->abortReason; - } + /** + * Whether the pipeline has been aborted. + * + * @return bool + */ + public function isAborted(): bool { + return $this->aborted; + } - /** - * Application-level abort code (0 = unspecified). - * - * @return int - */ - public function getAbortCode(): int { - return $this->abortCode; - } + /** + * Reason passed to abort(), or '' if not aborted. + * + * @return string + */ + public function getAbortReason(): string { + return $this->abortReason; + } - // ───────────────────────────────────────────────────────── - // Attribute bag (middleware communication) - // ───────────────────────────────────────────────────────── + /** + * Application-level abort code (0 = unspecified). + * + * @return int + */ + public function getAbortCode(): int { + return $this->abortCode; + } - /** - * Store an arbitrary value for downstream middleware to read. - */ - public function set(string $key, mixed $value): void { - $this->attributes[$key] = $value; - } + // ───────────────────────────────────────────────────────── + // Attribute bag (middleware communication) + // ───────────────────────────────────────────────────────── - /** - * Read an attribute set by an earlier middleware. - * - * @param string $key Attribute key. - * @param mixed $default Value returned when the key is absent. - * @return mixed The stored value, or $default. - */ - public function get(string $key, mixed $default = null): mixed { - return $this->attributes[$key] ?? $default; - } + /** + * Store an arbitrary value for downstream middleware to read. + */ + public function set(string $key, mixed $value): void { + $this->attributes[$key] = $value; + } - /** - * Whether an attribute exists in the bag. - * - * @param string $key Attribute key. - * @return bool - */ - public function has(string $key): bool { - return array_key_exists($key, $this->attributes); - } + /** + * Read an attribute set by an earlier middleware. + * + * @param string $key Attribute key. + * @param mixed $default Value returned when the key is absent. + * @return mixed The stored value, or $default. + */ + public function get(string $key, mixed $default = null): mixed { + return $this->attributes[$key] ?? $default; + } + + /** + * Whether an attribute exists in the bag. + * + * @param string $key Attribute key. + * @return bool + */ + public function has(string $key): bool { + return array_key_exists($key, $this->attributes); + } } diff --git a/src/Core/Http/Pipeline/StreamMiddlewareInterface.php b/src/Core/Http/Pipeline/StreamMiddlewareInterface.php index cca46a20..c3936e48 100644 --- a/src/Core/Http/Pipeline/StreamMiddlewareInterface.php +++ b/src/Core/Http/Pipeline/StreamMiddlewareInterface.php @@ -18,24 +18,23 @@ namespace XcVm\Core\Http\Pipeline; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface StreamMiddlewareInterface { + /** + * Handle the stream context and pass control to the next middleware. + * + * @param StreamContext $ctx Mutable context object + * @param callable(StreamContext): StreamContext $next Next middleware in chain + */ + public function handle(StreamContext $ctx, callable $next): StreamContext; - /** - * Handle the stream context and pass control to the next middleware. - * - * @param StreamContext $ctx Mutable context object - * @param callable(StreamContext): StreamContext $next Next middleware in chain - */ - public function handle(StreamContext $ctx, callable $next): StreamContext; - - /** - * Execution priority — higher value runs first. - * - * Core reserved ranges: - * 100 = AuthStreamMiddleware - * 90 = PermissionMiddleware - * 80 = ConnectionLimitMiddleware - * 0–79 = module middleware - * -1 = ExecuteMiddleware (terminal) - */ - public function getPriority(): int; + /** + * Execution priority — higher value runs first. + * + * Core reserved ranges: + * 100 = AuthStreamMiddleware + * 90 = PermissionMiddleware + * 80 = ConnectionLimitMiddleware + * 0–79 = module middleware + * -1 = ExecuteMiddleware (terminal) + */ + public function getPriority(): int; } diff --git a/src/Core/Http/Pipeline/StreamPipeline.php b/src/Core/Http/Pipeline/StreamPipeline.php index e7fdc51e..15398c05 100644 --- a/src/Core/Http/Pipeline/StreamPipeline.php +++ b/src/Core/Http/Pipeline/StreamPipeline.php @@ -39,76 +39,75 @@ namespace XcVm\Core\Http\Pipeline; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ final class StreamPipeline { + /** @var StreamMiddlewareInterface[] Sorted by priority descending */ + private array $middleware = []; - /** @var StreamMiddlewareInterface[] Sorted by priority descending */ - private array $middleware = []; + private bool $sorted = true; - private bool $sorted = true; + /** + * Add a middleware to the pipeline. + * + * Inserting middleware triggers a re-sort on next run(). + */ + public function pipe(StreamMiddlewareInterface $middleware): static { + $this->middleware[] = $middleware; + $this->sorted = false; + return $this; + } - /** - * Add a middleware to the pipeline. - * - * Inserting middleware triggers a re-sort on next run(). - */ - public function pipe(StreamMiddlewareInterface $middleware): static { - $this->middleware[] = $middleware; - $this->sorted = false; - return $this; - } + /** + * Execute the pipeline against the given context. + * + * Builds a recursive closure chain and invokes it. Each middleware in + * the chain may call $next($ctx) to continue, or skip it to abort. + * Aborted contexts are passed through without calling further middleware. + */ + public function run(StreamContext $ctx): StreamContext { + if (!$this->sorted) { + usort( + $this->middleware, + fn(StreamMiddlewareInterface $a, StreamMiddlewareInterface $b) => + $b->getPriority() <=> $a->getPriority() + ); + $this->sorted = true; + } - /** - * Execute the pipeline against the given context. - * - * Builds a recursive closure chain and invokes it. Each middleware in - * the chain may call $next($ctx) to continue, or skip it to abort. - * Aborted contexts are passed through without calling further middleware. - */ - public function run(StreamContext $ctx): StreamContext { - if (!$this->sorted) { - usort( - $this->middleware, - fn(StreamMiddlewareInterface $a, StreamMiddlewareInterface $b) => - $b->getPriority() <=> $a->getPriority() - ); - $this->sorted = true; - } + $chain = $this->buildChain($this->middleware); + return $chain($ctx); + } - $chain = $this->buildChain($this->middleware); - return $chain($ctx); - } + /** + * Return all registered middleware in their current sort order. + * + * @return StreamMiddlewareInterface[] + */ + public function getMiddleware(): array { + return $this->middleware; + } - /** - * Return all registered middleware in their current sort order. - * - * @return StreamMiddlewareInterface[] - */ - public function getMiddleware(): array { - return $this->middleware; - } + /** + * Build the recursive closure chain from the middleware array. + * + * Iterates in reverse so the first element in $stack becomes the + * outermost function (called first when the chain is invoked). + * + * @param StreamMiddlewareInterface[] $stack + * @return callable(StreamContext): StreamContext + */ + private function buildChain(array $stack): callable { + $terminal = static fn(StreamContext $ctx): StreamContext => $ctx; - /** - * Build the recursive closure chain from the middleware array. - * - * Iterates in reverse so the first element in $stack becomes the - * outermost function (called first when the chain is invoked). - * - * @param StreamMiddlewareInterface[] $stack - * @return callable(StreamContext): StreamContext - */ - private function buildChain(array $stack): callable { - $terminal = static fn(StreamContext $ctx): StreamContext => $ctx; - - return array_reduce( - array_reverse($stack), - static function (callable $next, StreamMiddlewareInterface $mw): callable { - return static function (StreamContext $ctx) use ($mw, $next): StreamContext { - if ($ctx->isAborted()) { - return $ctx; - } - return $mw->handle($ctx, $next); - }; - }, - $terminal - ); - } + return array_reduce( + array_reverse($stack), + static function (callable $next, StreamMiddlewareInterface $mw): callable { + return static function (StreamContext $ctx) use ($mw, $next): StreamContext { + if ($ctx->isAborted()) { + return $ctx; + } + return $mw->handle($ctx, $next); + }; + }, + $terminal + ); + } } diff --git a/src/Core/Http/Request.php b/src/Core/Http/Request.php index 79c3bcb6..1211364b 100644 --- a/src/Core/Http/Request.php +++ b/src/Core/Http/Request.php @@ -26,407 +26,397 @@ namespace XcVm\Core\Http; */ class Request { + /** @var array Cleaned $_GET data */ + protected $query = []; - /** @var array Cleaned $_GET data */ - protected $query = []; + /** @var array Cleaned $_POST data */ + protected $post = []; - /** @var array Cleaned $_POST data */ - protected $post = []; + /** @var array Cleaned $_COOKIE data */ + protected $cookies = []; - /** @var array Cleaned $_COOKIE data */ - protected $cookies = []; + /** @var array $_SERVER data (not cleaned — contains binary/paths) */ + protected $server = []; - /** @var array $_SERVER data (not cleaned — contains binary/paths) */ - protected $server = []; + /** @var array Merged input (post + query) */ + protected $input = []; - /** @var array Merged input (post + query) */ - protected $input = []; + /** @var string Raw POST body */ + protected $rawBody = null; - /** @var string Raw POST body */ - protected $rawBody = null; + /** @var string Client IP address */ + protected $clientIp = null; - /** @var string Client IP address */ - protected $clientIp = null; + /** @var Request|null Captured singleton for static access */ + protected static $captured = null; - /** @var Request|null Captured singleton for static access */ - protected static $captured = null; + /** + * Create from raw superglobals + * + * @param array $query $_GET + * @param array $post $_POST + * @param array $server $_SERVER + * @param array $cookies $_COOKIE + */ + public function __construct(array $query = [], array $post = [], array $server = [], array $cookies = []) { + // Clean input data + self::cleanGlobals($query); + self::cleanGlobals($post); + self::cleanGlobals($cookies); - /** - * Create from raw superglobals - * - * @param array $query $_GET - * @param array $post $_POST - * @param array $server $_SERVER - * @param array $cookies $_COOKIE - */ - public function __construct(array $query = [], array $post = [], array $server = [], array $cookies = []) { - // Clean input data - self::cleanGlobals($query); - self::cleanGlobals($post); - self::cleanGlobals($cookies); + $this->query = self::parseIncomingRecursively($query); + $this->post = self::parseIncomingRecursively($post); + $this->cookies = $cookies; + $this->server = $server; - $this->query = self::parseIncomingRecursively($query); - $this->post = self::parseIncomingRecursively($post); - $this->cookies = $cookies; - $this->server = $server; + // POST takes priority over GET (same as $_REQUEST default) + $this->input = array_merge($this->query, $this->post); + } - // POST takes priority over GET (same as $_REQUEST default) - $this->input = array_merge($this->query, $this->post); - } + /** + * Capture current request from PHP superglobals + * + * Creates a Request object from the current $_GET, $_POST, + * $_SERVER, $_COOKIE. Also cleans the original superglobals + * for backward compatibility. + * + * @return Request + */ + public static function capture() { + if (self::$captured !== null) { + return self::$captured; + } - /** - * Capture current request from PHP superglobals - * - * Creates a Request object from the current $_GET, $_POST, - * $_SERVER, $_COOKIE. Also cleans the original superglobals - * for backward compatibility. - * - * @return Request - */ - public static function capture() { - if (self::$captured !== null) { - return self::$captured; - } + // Clean original superglobals (backward compat — old code reads them directly) + self::cleanGlobals($_GET); + self::cleanGlobals($_POST); + self::cleanGlobals($_REQUEST); + self::cleanGlobals($_COOKIE); - // Clean original superglobals (backward compat — old code reads them directly) - self::cleanGlobals($_GET); - self::cleanGlobals($_POST); - self::cleanGlobals($_REQUEST); - self::cleanGlobals($_COOKIE); + self::$captured = new self($_GET, $_POST, $_SERVER, $_COOKIE); - self::$captured = new self($_GET, $_POST, $_SERVER, $_COOKIE); + return self::$captured; + } - return self::$captured; - } + /** + * Get the current captured instance (or null if not captured) + * + * @return Request|null + */ + public static function getInstance() { + return self::$captured; + } - /** - * Get the current captured instance (or null if not captured) - * - * @return Request|null - */ - public static function getInstance() { - return self::$captured; - } + // ─────────────────────────────────────────────────────────── + // Input Access + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Input Access - // ─────────────────────────────────────────────────────────── + /** + * Get a value from merged input (POST first, then GET) + * + * @param string $key Parameter name + * @param mixed $default Default if not found + * @return mixed + */ + public function input(string $key, mixed $default = null) { + return isset($this->input[$key]) ? $this->input[$key] : $default; + } - /** - * Get a value from merged input (POST first, then GET) - * - * @param string $key Parameter name - * @param mixed $default Default if not found - * @return mixed - */ - public function input($key, $default = null) { - return isset($this->input[$key]) ? $this->input[$key] : $default; - } + /** + * Get a value from $_GET (query string) + * + * @param string|null $key Parameter name (null returns all) + * @param mixed $default Default if not found + * @return mixed + */ + public function get(?string $key = null, mixed $default = null) { + if ($key === null) { + return $this->query; + } + return isset($this->query[$key]) ? $this->query[$key] : $default; + } - /** - * Get a value from $_GET (query string) - * - * @param string|null $key Parameter name (null returns all) - * @param mixed $default Default if not found - * @return mixed - */ - public function get($key = null, $default = null) { - if ($key === null) { - return $this->query; - } - return isset($this->query[$key]) ? $this->query[$key] : $default; - } + /** + * Get a value from $_POST + * + * @param string|null $key Parameter name (null returns all) + * @param mixed $default Default if not found + * @return mixed + */ + public function post(?string $key = null, mixed $default = null) { + if ($key === null) { + return $this->post; + } + return isset($this->post[$key]) ? $this->post[$key] : $default; + } - /** - * Get a value from $_POST - * - * @param string|null $key Parameter name (null returns all) - * @param mixed $default Default if not found - * @return mixed - */ - public function post($key = null, $default = null) { - if ($key === null) { - return $this->post; - } - return isset($this->post[$key]) ? $this->post[$key] : $default; - } + /** + * Get all merged input data + * + * @return array + */ + public function all() { + return $this->input; + } - /** - * Get all merged input data - * - * @return array - */ - public function all() { - return $this->input; - } + /** + * Check if a key exists in input + * + * @return bool + */ + public function has(string $key) { + return isset($this->input[$key]); + } - /** - * Check if a key exists in input - * - * @param string $key - * @return bool - */ - public function has($key) { - return isset($this->input[$key]); - } + /** + * Get an integer value from input + * + * @return int + */ + public function getInt(string $key, int $default = 0) { + return intval($this->input($key, $default)); + } - /** - * Get an integer value from input - * - * @param string $key - * @param int $default - * @return int - */ - public function getInt($key, $default = 0) { - return intval($this->input($key, $default)); - } + /** + * Get a boolean value from input + * + * @return bool + */ + public function getBool(string $key, bool $default = false) { + $val = $this->input($key, $default); + return filter_var($val, FILTER_VALIDATE_BOOLEAN); + } - /** - * Get a boolean value from input - * - * @param string $key - * @param bool $default - * @return bool - */ - public function getBool($key, $default = false) { - $val = $this->input($key, $default); - return filter_var($val, FILTER_VALIDATE_BOOLEAN); - } + // ─────────────────────────────────────────────────────────── + // Server / Headers + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Server / Headers - // ─────────────────────────────────────────────────────────── + /** + * Get a $_SERVER value + * + * @param string $key Server key (e.g., 'REQUEST_METHOD') + * @return mixed + */ + public function server(string $key, mixed $default = null) { + return isset($this->server[$key]) ? $this->server[$key] : $default; + } - /** - * Get a $_SERVER value - * - * @param string $key Server key (e.g., 'REQUEST_METHOD') - * @param mixed $default - * @return mixed - */ - public function server($key, $default = null) { - return isset($this->server[$key]) ? $this->server[$key] : $default; - } + /** + * Get a cookie value + * + * @param string $key Cookie name + * @return mixed + */ + public function cookie(string $key, mixed $default = null) { + return isset($this->cookies[$key]) ? $this->cookies[$key] : $default; + } - /** - * Get a cookie value - * - * @param string $key Cookie name - * @param mixed $default - * @return mixed - */ - public function cookie($key, $default = null) { - return isset($this->cookies[$key]) ? $this->cookies[$key] : $default; - } + /** + * Get HTTP request method + * + * @return string GET, POST, PUT, DELETE, etc. + */ + public function method() { + return $this->server('REQUEST_METHOD', 'GET'); + } - /** - * Get HTTP request method - * - * @return string GET, POST, PUT, DELETE, etc. - */ - public function method() { - return $this->server('REQUEST_METHOD', 'GET'); - } + /** + * Check if request is POST + * + * @return bool + */ + public function isPost() { + return $this->method() === 'POST'; + } - /** - * Check if request is POST - * - * @return bool - */ - public function isPost() { - return $this->method() === 'POST'; - } + /** + * Check if request appears to be AJAX + * + * @return bool + */ + public function isAjax() { + return strtolower($this->server('HTTP_X_REQUESTED_WITH', '')) === 'xmlhttprequest'; + } - /** - * Check if request appears to be AJAX - * - * @return bool - */ - public function isAjax() { - return strtolower($this->server('HTTP_X_REQUESTED_WITH', '')) === 'xmlhttprequest'; - } + /** + * Get the request URI + * + * @return string + */ + public function uri() { + return $this->server('REQUEST_URI', '/'); + } - /** - * Get the request URI - * - * @return string - */ - public function uri() { - return $this->server('REQUEST_URI', '/'); - } + /** + * Get the User-Agent string + * + * @return string + */ + public function userAgent() { + return $this->server('HTTP_USER_AGENT', ''); + } - /** - * Get the User-Agent string - * - * @return string - */ - public function userAgent() { - return $this->server('HTTP_USER_AGENT', ''); - } + /** + * Get the client IP address + * + * Checks X-Forwarded-For and X-Real-IP headers for proxied requests. + * + * @return string + */ + public function ip() { + if ($this->clientIp !== null) { + return $this->clientIp; + } - /** - * Get the client IP address - * - * Checks X-Forwarded-For and X-Real-IP headers for proxied requests. - * - * @return string - */ - public function ip() { - if ($this->clientIp !== null) { - return $this->clientIp; - } + // Check common proxy headers + $headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR']; - // Check common proxy headers - $headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR']; + foreach ($headers as $header) { + $value = $this->server($header); + if (!empty($value)) { + // X-Forwarded-For may contain multiple IPs — take the first + $ip = trim(explode(',', $value)[0]); + if (filter_var($ip, FILTER_VALIDATE_IP)) { + $this->clientIp = $ip; + return $ip; + } + } + } - foreach ($headers as $header) { - $value = $this->server($header); - if (!empty($value)) { - // X-Forwarded-For may contain multiple IPs — take the first - $ip = trim(explode(',', $value)[0]); - if (filter_var($ip, FILTER_VALIDATE_IP)) { - $this->clientIp = $ip; - return $ip; - } - } - } + $this->clientIp = '0.0.0.0'; + return $this->clientIp; + } - $this->clientIp = '0.0.0.0'; - return $this->clientIp; - } + /** + * Get the raw POST body + * + * @return string + */ + public function rawBody() { + if ($this->rawBody === null) { + $this->rawBody = file_get_contents('php://input') ?: ''; + } + return $this->rawBody; + } - /** - * Get the raw POST body - * - * @return string - */ - public function rawBody() { - if ($this->rawBody === null) { - $this->rawBody = file_get_contents('php://input') ?: ''; - } - return $this->rawBody; - } + /** + * Decode JSON from POST body + * + * @param bool $assoc Return as associative array + * @return mixed|null + */ + public function json(bool $assoc = true) { + $body = $this->rawBody(); + if (empty($body)) { + return null; + } + return json_decode($body, $assoc); + } - /** - * Decode JSON from POST body - * - * @param bool $assoc Return as associative array - * @return mixed|null - */ - public function json($assoc = true) { - $body = $this->rawBody(); - if (empty($body)) { - return null; - } - return json_decode($body, $assoc); - } + /** + * Get the Host header + * + * @return string + */ + public function host() { + return $this->server('HTTP_HOST', $this->server('SERVER_NAME', '')); + } - /** - * Get the Host header - * - * @return string - */ - public function host() { - return $this->server('HTTP_HOST', $this->server('SERVER_NAME', '')); - } + // ─────────────────────────────────────────────────────────── + // Static Sanitization Methods (backward compatibility) + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Static Sanitization Methods (backward compatibility) - // ─────────────────────────────────────────────────────────── + /** + * Clean dangerous characters from input data (recursive) + * + * Replaces NULL bytes, path traversal, and RTL override characters. + * Modifies the input array IN PLACE. + * + * @param array &$rData Input data (modified in place) + * @param int $rIteration Recursion depth counter + */ + public static function cleanGlobals(array &$rData, int $rIteration = 0) { + if (10 > $rIteration) { + foreach ($rData as $rKey => $rValue) { + if (is_array($rValue)) { + self::cleanGlobals($rData[$rKey], ++$rIteration); + } else { + $rValue = str_replace(chr(0), '', $rValue); + $rValue = str_replace("\x0", '', $rValue); + $rValue = str_replace('../', '../', $rValue); + $rValue = str_replace('‮', '', $rValue); + $rData[$rKey] = $rValue; + } + } + } else { + return null; + } + } - /** - * Clean dangerous characters from input data (recursive) - * - * Replaces NULL bytes, path traversal, and RTL override characters. - * Modifies the input array IN PLACE. - * - * @param array &$rData Input data (modified in place) - * @param int $rIteration Recursion depth counter - */ - public static function cleanGlobals(&$rData, $rIteration = 0) { - if (10 > $rIteration) { - foreach ($rData as $rKey => $rValue) { - if (is_array($rValue)) { - self::cleanGlobals($rData[$rKey], ++$rIteration); - } else { - $rValue = str_replace(chr(0), '', $rValue); - $rValue = str_replace("\x0", '', $rValue); - $rValue = str_replace('../', '../', $rValue); - $rValue = str_replace('‮', '', $rValue); - $rData[$rKey] = $rValue; - } - } - } else { - return null; - } - } + /** + * Parse and clean input recursively + * + * Sanitizes both keys and values. Returns a new clean array. + * + * @param array &$rData Raw input data + * @param array $rInput Accumulator (internal use) + * @param int $rIteration Recursion depth counter + * @return array Cleaned data + */ + public static function parseIncomingRecursively(array &$rData, array $rInput = [], int $rIteration = 0) { + if (20 > $rIteration) { + if (is_array($rData)) { + foreach ($rData as $rKey => $rValue) { + if (is_array($rValue)) { + $rInput[$rKey] = self::parseIncomingRecursively($rData[$rKey], [], $rIteration + 1); + } else { + $rKey = self::parseCleanKey($rKey); + $rValue = self::parseCleanValue($rValue); + $rInput[$rKey] = $rValue; + } + } + return $rInput; + } else { + return $rInput; + } + } else { + return $rInput; + } + } - /** - * Parse and clean input recursively - * - * Sanitizes both keys and values. Returns a new clean array. - * - * @param array &$rData Raw input data - * @param array $rInput Accumulator (internal use) - * @param int $rIteration Recursion depth counter - * @return array Cleaned data - */ - public static function parseIncomingRecursively(&$rData, $rInput = [], $rIteration = 0) { - if (20 > $rIteration) { - if (is_array($rData)) { - foreach ($rData as $rKey => $rValue) { - if (is_array($rValue)) { - $rInput[$rKey] = self::parseIncomingRecursively($rData[$rKey], [], $rIteration + 1); - } else { - $rKey = self::parseCleanKey($rKey); - $rValue = self::parseCleanValue($rValue); - $rInput[$rKey] = $rValue; - } - } - return $rInput; - } else { - return $rInput; - } - } else { - return $rInput; - } - } + /** + * Sanitize an input key + * + * @return string + */ + public static function parseCleanKey(string $rKey) { + if ($rKey !== '') { + $rKey = htmlspecialchars(urldecode($rKey)); + $rKey = str_replace('..', '', $rKey); + $rKey = preg_replace('/\\_\\_(.+?)\\_\\_/', '', $rKey); + return preg_replace('/^([\\w\\.\\-\\_]+)$/', '$1', $rKey); + } + return ''; + } - /** - * Sanitize an input key - * - * @param string $rKey - * @return string - */ - public static function parseCleanKey($rKey) { - if ($rKey !== '') { - $rKey = htmlspecialchars(urldecode($rKey)); - $rKey = str_replace('..', '', $rKey); - $rKey = preg_replace('/\\_\\_(.+?)\\_\\_/', '', $rKey); - return preg_replace('/^([\\w\\.\\-\\_]+)$/', '$1', $rKey); - } - return ''; - } - - /** - * Sanitize an input value - * - * Strips dangerous HTML patterns and normalizes line breaks. - * - * @param string $rValue - * @return string - */ - public static function parseCleanValue($rValue) { - if ($rValue != '') { - $rValue = str_replace(' ', ' ', stripslashes($rValue)); - $rValue = str_replace(["\r\n", "\n\r", "\r"], "\n", $rValue); - $rValue = str_replace('', '-->', $rValue); - $rValue = str_ireplace('', '-->', $rValue); + $rValue = str_ireplace(' $message], $extra); + self::json($payload, $statusCode); + } - /** - * Send a JSON error response and exit - * - * @param string $message Error message - * @param int $statusCode HTTP status code - * @param array $extra Additional fields to include - */ - public static function jsonError($message, $statusCode = 400, array $extra = []) { - $payload = array_merge(['error' => $message], $extra); - self::json($payload, $statusCode); - } + /** + * Send a redirect response and exit + * + * @param string $url Target URL + * @param int $statusCode 301 (permanent) or 302 (temporary) + */ + public static function redirect(string $url, int $statusCode = 302) { + http_response_code($statusCode); + header('Location: ' . $url); + exit; + } - /** - * Send a redirect response and exit - * - * @param string $url Target URL - * @param int $statusCode 301 (permanent) or 302 (temporary) - */ - public static function redirect($url, $statusCode = 302) { - http_response_code($statusCode); - header('Location: ' . $url); - exit; - } + /** + * Send a 404 Not Found response and exit + * + * @param string $message Optional message + */ + public static function notFound(string $message = 'Not Found') { + http_response_code(404); + header('Content-Type: text/plain'); + echo $message; + exit; + } - /** - * Send a 404 Not Found response and exit - * - * @param string $message Optional message - */ - public static function notFound($message = 'Not Found') { - http_response_code(404); - header('Content-Type: text/plain'); - echo $message; - exit; - } + /** + * Send arbitrary HTTP header + * + * @param string $name Header name + * @param string $value Header value + */ + public static function header(string $name, string $value) { + header($name . ': ' . $value); + } - /** - * Send arbitrary HTTP header - * - * @param string $name Header name - * @param string $value Header value - */ - public static function header($name, $value) { - header($name . ': ' . $value); - } + /** + * Set CORS headers (Access-Control-Allow-Origin: *) + * + * Matches current behavior in nginx config and auth.php + */ + public static function cors() { + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type, Authorization'); + } - /** - * Set CORS headers (Access-Control-Allow-Origin: *) - * - * Matches current behavior in nginx config and auth.php - */ - public static function cors() { - header('Access-Control-Allow-Origin: *'); - header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); - header('Access-Control-Allow-Headers: Content-Type, Authorization'); - } + /** + * Set no-cache headers + * + * Used for HLS playlists and auth responses that must not be cached. + */ + public static function noCache() { + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + header('Expires: 0'); + } - /** - * Set no-cache headers - * - * Used for HLS playlists and auth responses that must not be cached. - */ - public static function noCache() { - header('Cache-Control: no-store, no-cache, must-revalidate'); - header('Pragma: no-cache'); - header('Expires: 0'); - } + /** + * Send raw content with content type and exit + * + * @param string $content Body content + * @param string $contentType MIME type + * @param int $statusCode HTTP status code + */ + public static function raw(string $content, string $contentType = 'text/plain', int $statusCode = 200) { + http_response_code($statusCode); + header('Content-Type: ' . $contentType); + echo $content; + exit; + } - /** - * Send raw content with content type and exit - * - * @param string $content Body content - * @param string $contentType MIME type - * @param int $statusCode HTTP status code - */ - public static function raw($content, $contentType = 'text/plain', $statusCode = 200) { - http_response_code($statusCode); - header('Content-Type: ' . $contentType); - echo $content; - exit; - } - - /** - * Send an empty response with status code and exit - * - * @param int $statusCode - */ - public static function empty($statusCode = 204) { - http_response_code($statusCode); - exit; - } + /** + * Send an empty response with status code and exit + * + */ + public static function empty(int $statusCode = 204) { + http_response_code($statusCode); + exit; + } } diff --git a/src/Core/Http/Router.php b/src/Core/Http/Router.php index d9b27ea2..3cb55f11 100644 --- a/src/Core/Http/Router.php +++ b/src/Core/Http/Router.php @@ -73,406 +73,402 @@ use XcVm\Core\Util\AdminHelpers; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class Router { + /** + * Registered GET (page) routes. + * Shape: ['route/path' => ['handler' => callable, 'middleware' => [...], 'permission' => [...]]] + * @var array + */ + protected array $getRoutes = []; - /** - * Registered GET (page) routes. - * Shape: ['route/path' => ['handler' => callable, 'middleware' => [...], 'permission' => [...]]] - * @var array - */ - protected array $getRoutes = []; + /** + * Registered POST routes. + * @var array + */ + protected array $postRoutes = []; - /** - * Registered POST routes. - * @var array - */ - protected array $postRoutes = []; + /** + * API routes (JSON response), keyed by action name. + * @var array + */ + protected array $apiRoutes = []; - /** - * API routes (JSON response), keyed by action name. - * @var array - */ - protected array $apiRoutes = []; + /** + * Preserve existing routes during the module registration phase. + * When enabled, duplicate route keys are skipped instead of overwritten. + */ + protected bool $preserveExistingRoutes = false; - /** - * Preserve existing routes during the module registration phase. - * When enabled, duplicate route keys are skipped instead of overwritten. - */ - protected bool $preserveExistingRoutes = false; + /** + * Route/API collisions collected during the preserve phase. + * @var array + */ + protected array $routeCollisions = []; - /** - * Route/API collisions collected during the preserve phase. - * @var array - */ - protected array $routeCollisions = []; + /** Current group prefix. */ + protected string $groupPrefix = ''; - /** Current group prefix. */ - protected string $groupPrefix = ''; + /** + * Current middleware stack for the active group. + * @var array + */ + protected array $groupMiddleware = []; - /** - * Current middleware stack for the active group. - * @var array - */ - protected array $groupMiddleware = []; + /** + * Current permission spec for the active group. + * @var array + */ + protected array $groupPermission = []; - /** - * Current permission spec for the active group. - * @var array - */ - protected array $groupPermission = []; + /** Singleton instance. */ + protected static ?Router $instance = null; - /** Singleton instance. */ - protected static ?Router $instance = null; + /** + * Get the singleton instance. + */ + public static function getInstance(): self { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } - /** - * Get the singleton instance. - */ - public static function getInstance(): self { - if (self::$instance === null) { - self::$instance = new self(); - } - return self::$instance; - } + /** + * Reset the singleton (used by tests). + */ + public static function resetInstance(): void { + self::$instance = null; + } - /** - * Reset the singleton (used by tests). - */ - public static function resetInstance(): void { - self::$instance = null; - } + // ─────────────────────────────────────────────────────────── + // Route registration + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Route registration - // ─────────────────────────────────────────────────────────── + /** + * Register a GET route (page). + * + * @param string $route Route path (e.g. 'watch', 'watch/add') + * @param callable|array $handler Handler: [ClassName, 'method'] or a callable + * @param array $options Extra options: 'permission' => ['type', 'key'], 'middleware' => [...] + */ + public function get(string $route, callable|array $handler, array $options = []): self { + $fullRoute = $this->buildRoute($route); - /** - * Register a GET route (page). - * - * @param string $route Route path (e.g. 'watch', 'watch/add') - * @param callable|array $handler Handler: [ClassName, 'method'] or a callable - * @param array $options Extra options: 'permission' => ['type', 'key'], 'middleware' => [...] - */ - public function get(string $route, $handler, array $options = []): self { - $fullRoute = $this->buildRoute($route); + if ($this->preserveExistingRoutes && isset($this->getRoutes[$fullRoute])) { + $this->routeCollisions[] = ['type' => 'get', 'key' => $fullRoute]; + return $this; + } - if ($this->preserveExistingRoutes && isset($this->getRoutes[$fullRoute])) { - $this->routeCollisions[] = ['type' => 'get', 'key' => $fullRoute]; - return $this; - } + $this->getRoutes[$fullRoute] = $this->buildRouteEntry($handler, $options); + return $this; + } - $this->getRoutes[$fullRoute] = $this->buildRouteEntry($handler, $options); - return $this; - } + /** + * Register a POST route (form handling). + * + * @param string $route Route path + * @param callable|array $handler Handler + * @param array $options Extra options + */ + public function post(string $route, callable|array $handler, array $options = []): self { + $fullRoute = $this->buildRoute($route); - /** - * Register a POST route (form handling). - * - * @param string $route Route path - * @param callable|array $handler Handler - * @param array $options Extra options - */ - public function post(string $route, $handler, array $options = []): self { - $fullRoute = $this->buildRoute($route); + if ($this->preserveExistingRoutes && isset($this->postRoutes[$fullRoute])) { + $this->routeCollisions[] = ['type' => 'post', 'key' => $fullRoute]; + return $this; + } - if ($this->preserveExistingRoutes && isset($this->postRoutes[$fullRoute])) { - $this->routeCollisions[] = ['type' => 'post', 'key' => $fullRoute]; - return $this; - } + $this->postRoutes[$fullRoute] = $this->buildRouteEntry($handler, $options); + return $this; + } - $this->postRoutes[$fullRoute] = $this->buildRouteEntry($handler, $options); - return $this; - } + /** + * Register a route for both GET and POST. + * + * @param string $route Route path + * @param callable|array $handler Handler + * @param array $options Extra options + */ + public function any(string $route, callable|array $handler, array $options = []): self { + $this->get($route, $handler, $options); + $this->post($route, $handler, $options); + return $this; + } - /** - * Register a route for both GET and POST. - * - * @param string $route Route path - * @param callable|array $handler Handler - * @param array $options Extra options - */ - public function any(string $route, $handler, array $options = []): self { - $this->get($route, $handler, $options); - $this->post($route, $handler, $options); - return $this; - } + /** + * Register an API route (JSON response via action=...). + * + * API routes are dispatched through admin/api.php by action name. When + * $router->dispatchApi('watch_enable') is called, the Router looks up the + * registered route and invokes its handler. + * + * @param string $action Action name (e.g. 'enable_watch', 'disable_plex') + * @param callable|array $handler Handler + * @param array $options Extra options: 'permission' => ['type', 'key'] + */ + public function api(string $action, callable|array $handler, array $options = []): self { + $fullAction = $this->groupPrefix ? $this->groupPrefix . '_' . $action : $action; - /** - * Register an API route (JSON response via action=...). - * - * API routes are dispatched through admin/api.php by action name. When - * $router->dispatchApi('watch_enable') is called, the Router looks up the - * registered route and invokes its handler. - * - * @param string $action Action name (e.g. 'enable_watch', 'disable_plex') - * @param callable|array $handler Handler - * @param array $options Extra options: 'permission' => ['type', 'key'] - */ - public function api(string $action, $handler, array $options = []): self { - $fullAction = $this->groupPrefix ? $this->groupPrefix . '_' . $action : $action; + if ($this->preserveExistingRoutes && isset($this->apiRoutes[$fullAction])) { + $this->routeCollisions[] = ['type' => 'api', 'key' => $fullAction]; + return $this; + } - if ($this->preserveExistingRoutes && isset($this->apiRoutes[$fullAction])) { - $this->routeCollisions[] = ['type' => 'api', 'key' => $fullAction]; - return $this; - } + $this->apiRoutes[$fullAction] = $this->buildRouteEntry($handler, $options); + return $this; + } - $this->apiRoutes[$fullAction] = $this->buildRouteEntry($handler, $options); - return $this; - } + /** + * Enable safe module registration mode. + * Existing routes keep priority; duplicates are collected as collisions. + * + */ + public function beginModuleRegistration(): self { + $this->preserveExistingRoutes = true; + return $this; + } - /** - * Enable safe module registration mode. - * Existing routes keep priority; duplicates are collected as collisions. - * - */ - public function beginModuleRegistration(): self { - $this->preserveExistingRoutes = true; - return $this; - } + /** + * Disable safe module registration mode. + * + */ + public function endModuleRegistration(): self { + $this->preserveExistingRoutes = false; + return $this; + } - /** - * Disable safe module registration mode. - * - */ - public function endModuleRegistration(): self { - $this->preserveExistingRoutes = false; - return $this; - } + /** + * Return and clear the collected route collisions. + * + * @return array + */ + public function drainRouteCollisions(): array { + $collisions = $this->routeCollisions; + $this->routeCollisions = []; + return $collisions; + } - /** - * Return and clear the collected route collisions. - * - * @return array - */ - public function drainRouteCollisions(): array { - $collisions = $this->routeCollisions; - $this->routeCollisions = []; - return $collisions; - } + /** + * Group routes under a shared prefix, middleware and permissions. + * + * @param string $prefix Prefix (e.g. 'watch', 'plex') + * @param callable $callback function(Router $router) — registers the routes in the group + * @param array $options Group options: 'middleware' => [...], 'permission' => [...] + */ + public function group(string $prefix, callable $callback, array $options = []): self { + // Save the current context + $prevPrefix = $this->groupPrefix; + $prevMiddleware = $this->groupMiddleware; + $prevPermission = $this->groupPermission; - /** - * Group routes under a shared prefix, middleware and permissions. - * - * @param string $prefix Prefix (e.g. 'watch', 'plex') - * @param callable $callback function(Router $router) — registers the routes in the group - * @param array $options Group options: 'middleware' => [...], 'permission' => [...] - */ - public function group(string $prefix, callable $callback, array $options = []): self { - // Save the current context - $prevPrefix = $this->groupPrefix; - $prevMiddleware = $this->groupMiddleware; - $prevPermission = $this->groupPermission; + // Set the new context + $this->groupPrefix = $prevPrefix ? $prevPrefix . '/' . $prefix : $prefix; + $this->groupMiddleware = array_merge($prevMiddleware, $options['middleware'] ?? []); + $this->groupPermission = $options['permission'] ?? $prevPermission; - // Set the new context - $this->groupPrefix = $prevPrefix ? $prevPrefix . '/' . $prefix : $prefix; - $this->groupMiddleware = array_merge($prevMiddleware, $options['middleware'] ?? []); - $this->groupPermission = $options['permission'] ?? $prevPermission; + // Invoke the callback, which registers the routes + $callback($this); - // Invoke the callback, which registers the routes - $callback($this); + // Restore the previous context + $this->groupPrefix = $prevPrefix; + $this->groupMiddleware = $prevMiddleware; + $this->groupPermission = $prevPermission; - // Restore the previous context - $this->groupPrefix = $prevPrefix; - $this->groupMiddleware = $prevMiddleware; - $this->groupPermission = $prevPermission; + return $this; + } - return $this; - } + // ─────────────────────────────────────────────────────────── + // Dispatch + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Dispatch - // ─────────────────────────────────────────────────────────── + /** + * Resolve the page route and invoke its handler. + * + * @param string $page Page name from the URL (e.g. 'watch', 'plex_add' → 'plex/add') + * @param string $method HTTP method ('GET' or 'POST') + * @return bool true if a route was found and executed, false otherwise + */ + public function dispatch(string $page, string $method = 'GET'): bool { + // Normalize: 'plex_add' → 'plex/add', 'watch' → 'watch' + $route = $this->normalizePage($page); - /** - * Resolve the page route and invoke its handler. - * - * @param string $page Page name from the URL (e.g. 'watch', 'plex_add' → 'plex/add') - * @param string $method HTTP method ('GET' or 'POST') - * @return bool true if a route was found and executed, false otherwise - */ - public function dispatch(string $page, string $method = 'GET'): bool { - // Normalize: 'plex_add' → 'plex/add', 'watch' → 'watch' - $route = $this->normalizePage($page); + // Pick the route set based on the method + $routes = ($method === 'POST') ? $this->postRoutes : $this->getRoutes; - // Pick the route set based on the method - $routes = ($method === 'POST') ? $this->postRoutes : $this->getRoutes; + // Fallback: if no POST route matches, look it up among GET routes + if ($method === 'POST' && !isset($routes[$route]) && isset($this->getRoutes[$route])) { + $routes = $this->getRoutes; + } - // Fallback: if no POST route matches, look it up among GET routes - if ($method === 'POST' && !isset($routes[$route]) && isset($this->getRoutes[$route])) { - $routes = $this->getRoutes; - } + if (!isset($routes[$route])) { + return false; + } - if (!isset($routes[$route])) { - return false; - } + $entry = $routes[$route]; - $entry = $routes[$route]; + if (!$this->checkPermission($entry)) { + $this->denyAccess(); + return true; + } - if (!$this->checkPermission($entry)) { - $this->denyAccess(); - return true; - } + // Run middleware + foreach ($entry['middleware'] as $mw) { + if (is_callable($mw)) { + $result = call_user_func($mw); + if ($result === false) { + return true; // middleware halted execution + } + } + } - // Run middleware - foreach ($entry['middleware'] as $mw) { - if (is_callable($mw)) { - $result = call_user_func($mw); - if ($result === false) { - return true; // middleware halted execution - } - } - } + $this->callHandler($entry['handler']); + return true; + } - $this->callHandler($entry['handler']); - return true; - } + /** + * Resolve an API route and invoke its handler. + * + * Used in admin/api.php as a fallback for module-provided actions. Example: + * if action='enable_watch' was registered by a module, the Router invokes + * [WatchController::class, 'apiEnable']. + * + * @param string $action Action name (from $_GET['action']) + * @return bool true if a route was found and executed, false otherwise + */ + public function dispatchApi(string $action): bool { + if (!isset($this->apiRoutes[$action])) { + return false; + } - /** - * Resolve an API route and invoke its handler. - * - * Used in admin/api.php as a fallback for module-provided actions. Example: - * if action='enable_watch' was registered by a module, the Router invokes - * [WatchController::class, 'apiEnable']. - * - * @param string $action Action name (from $_GET['action']) - * @return bool true if a route was found and executed, false otherwise - */ - public function dispatchApi(string $action): bool { - if (!isset($this->apiRoutes[$action])) { - return false; - } + $entry = $this->apiRoutes[$action]; - $entry = $this->apiRoutes[$action]; + if (!$this->checkPermission($entry)) { + echo json_encode(['result' => false]); + exit(); + } - if (!$this->checkPermission($entry)) { - echo json_encode(['result' => false]); - exit(); - } + $this->callHandler($entry['handler']); + return true; + } - $this->callHandler($entry['handler']); - return true; - } + // ─────────────────────────────────────────────────────────── + // Internal helpers + // ─────────────────────────────────────────────────────────── - // ─────────────────────────────────────────────────────────── - // Internal helpers - // ─────────────────────────────────────────────────────────── + /** + * Build the full path taking groupPrefix into account. + */ + protected function buildRoute(string $route): string { + if ($this->groupPrefix && $route !== '') { + $full = $this->groupPrefix . '/' . $route; + } else { + $full = $this->groupPrefix ?: $route; + } + // Normalize on registration so dispatch() looks up the same key + return $this->normalizePage($full); + } - /** - * Build the full path taking groupPrefix into account. - */ - protected function buildRoute(string $route): string { - if ($this->groupPrefix && $route !== '') { - $full = $this->groupPrefix . '/' . $route; - } else { - $full = $this->groupPrefix ?: $route; - } - // Normalize on registration so dispatch() looks up the same key - return $this->normalizePage($full); - } + /** + * Assemble a route entry. + * + * @return array{handler:mixed,middleware:array,permission:mixed} + */ + protected function buildRouteEntry(callable|array $handler, array $options): array { + return [ + 'handler' => $handler, + 'middleware' => array_merge($this->groupMiddleware, $options['middleware'] ?? []), + 'permission' => $options['permission'] ?? $this->groupPermission, + ]; + } - /** - * Assemble a route entry. - * - * @param callable|array $handler - * @param array $options - * @return array{handler:mixed,middleware:array,permission:mixed} - */ - protected function buildRouteEntry($handler, array $options): array { - return [ - 'handler' => $handler, - 'middleware' => array_merge($this->groupMiddleware, $options['middleware'] ?? []), - 'permission' => $options['permission'] ?? $this->groupPermission, - ]; - } + /** + * Normalize a page name into a route. + * + * Converts legacy (admin-style) names into route format: + * 'watch' → 'watch' + * 'watch_add' → 'watch/add' + * 'settings_watch' → 'settings/watch' + * 'plex_add' → 'plex/add' + * 'settings_plex' → 'settings/plex' + */ + protected function normalizePage(string $page): string { + // Strip a trailing .php if present + $page = preg_replace('/\.php$/', '', $page); + // Convert _ to / + return str_replace('_', '/', $page); + } - /** - * Normalize a page name into a route. - * - * Converts legacy (admin-style) names into route format: - * 'watch' → 'watch' - * 'watch_add' → 'watch/add' - * 'settings_watch' → 'settings/watch' - * 'plex_add' → 'plex/add' - * 'settings_plex' → 'settings/plex' - */ - protected function normalizePage(string $page): string { - // Strip a trailing .php if present - $page = preg_replace('/\.php$/', '', $page); - // Convert _ to / - return str_replace('_', '/', $page); - } + /** + * Check the permissions for a route. + * + * @param array $entry Route entry + */ + protected function checkPermission(array $entry): bool { + if (empty($entry['permission'])) { + return true; + } - /** - * Check the permissions for a route. - * - * @param array $entry Route entry - */ - protected function checkPermission(array $entry): bool { - if (empty($entry['permission'])) { - return true; - } + $perm = $entry['permission']; - $perm = $entry['permission']; + // Support the ['type', 'key'] format for Authorization::check() + if (is_array($perm) && count($perm) === 2 && is_string($perm[0])) { + return Authorization::check($perm[0], $perm[1]); + } - // Support the ['type', 'key'] format for Authorization::check() - if (is_array($perm) && count($perm) === 2 && is_string($perm[0])) { - return Authorization::check($perm[0], $perm[1]); - } + // Arbitrary callable + if (is_callable($perm)) { + return call_user_func($perm); + } - // Arbitrary callable - if (is_callable($perm)) { - return call_user_func($perm); - } + return true; + } - return true; - } + /** + * Invoke a route handler. + * + * Supports: + * - [ClassName::class, 'method'] → (new ClassName())->method() + * - callable (closure) + * - [object, 'method'] + * + */ + protected function callHandler(callable|array $handler): void { + if (is_array($handler) && count($handler) === 2 && is_string($handler[0])) { + // [ClassName, 'method'] → instantiate (via the DI container if available) + $class = $handler[0]; + $method = $handler[1]; - /** - * Invoke a route handler. - * - * Supports: - * - [ClassName::class, 'method'] → (new ClassName())->method() - * - callable (closure) - * - [object, 'method'] - * - * @param callable|array $handler - */ - protected function callHandler($handler): void { - if (is_array($handler) && count($handler) === 2 && is_string($handler[0])) { - // [ClassName, 'method'] → instantiate (via the DI container if available) - $class = $handler[0]; - $method = $handler[1]; + // Resolve through the ServiceContainer (DI) when it is already loaded + if (class_exists(ServiceContainer::class, false)) { + $container = ServiceContainer::getInstance(); + try { + $obj = $container->get($class); + } catch (\Throwable) { + // Fallback: parameterless constructor + $obj = new $class(); + } + } else { + $obj = new $class(); + } - // Resolve through the ServiceContainer (DI) when it is already loaded - if (class_exists(ServiceContainer::class, false)) { - $container = ServiceContainer::getInstance(); - try { - $obj = $container->get($class); - } catch (\Throwable) { - // Fallback: parameterless constructor - $obj = new $class(); - } - } else { - $obj = new $class(); - } + $obj->$method(); + } elseif (is_callable($handler)) { + call_user_func($handler); + } + } - $obj->$method(); - } elseif (is_callable($handler)) { - call_user_func($handler); - } - } + /** + * Send an "access denied" response. + * + * Prefers redirecting an authenticated-but-unauthorized user to the + * dashboard; falls back to a bare 403 if the helper is unavailable. + */ + protected function denyAccess(): void { + if (class_exists(AdminHelpers::class)) { + AdminHelpers::goHome(); // redirects and exits + } - /** - * Send an "access denied" response. - * - * Prefers redirecting an authenticated-but-unauthorized user to the - * dashboard; falls back to a bare 403 if the helper is unavailable. - */ - protected function denyAccess(): void { - if (class_exists(AdminHelpers::class)) { - AdminHelpers::goHome(); // redirects and exits - } - - http_response_code(403); - echo 'Access denied'; - exit(); - } + http_response_code(403); + echo 'Access denied'; + exit(); + } } diff --git a/src/Core/Init/LegacyInitializer.php b/src/Core/Init/LegacyInitializer.php index e6e38246..6b79162f 100644 --- a/src/Core/Init/LegacyInitializer.php +++ b/src/Core/Init/LegacyInitializer.php @@ -38,7 +38,7 @@ class LegacyInitializer { * @param bool $rUseCache Load settings from cache instead of the database. * @return void */ - public static function initCore($rUseCache = false) { + public static function initCore(bool $rUseCache = false) { if (!empty($_GET)) { InputValidator::cleanGlobals($_GET); } @@ -104,14 +104,7 @@ class LegacyInitializer { $db->query("SELECT * FROM `crontab` WHERE `enabled` = 1;"); foreach ($db->get_rows() as $rRow) { $rJobs[] = - $rRow["time"] . - " " . - PHP_BIN . - " " . - MAIN_HOME . - "console.php cron:" . - $rRow["filename"] . - " # XC_VM"; + $rRow["time"] . " " . PHP_BIN . " " . MAIN_HOME . "console.php cron:" . $rRow["filename"] . " # XC_VM"; } shell_exec("crontab -r"); diff --git a/src/Core/Localization/Translator.php b/src/Core/Localization/Translator.php index 97d289f2..258596cc 100644 --- a/src/Core/Localization/Translator.php +++ b/src/Core/Localization/Translator.php @@ -224,4 +224,4 @@ class Translator { flock($fp, LOCK_UN); fclose($fp); } -} \ No newline at end of file +} diff --git a/src/Core/Logging/DatabaseLogger.php b/src/Core/Logging/DatabaseLogger.php index 7bcbd79a..df89d956 100644 --- a/src/Core/Logging/DatabaseLogger.php +++ b/src/Core/Logging/DatabaseLogger.php @@ -26,160 +26,160 @@ namespace XcVm\Core\Logging; require_once __DIR__ . '/LoggerInterface.php'; class DatabaseLogger implements LoggerInterface { - /** - * Путь к файлу лога клиентских запросов. - * - * @var string|null - */ - private static ?string $logFile = null; + /** + * Путь к файлу лога клиентских запросов. + * + * @var string|null + */ + private static ?string $logFile = null; - /** - * Настройка: включено ли сохранение клиентских логов. - * Соответствует настройке client_logs_save из $rSettings. - * - * @var int|null null = не задано (проверяем через StreamingUtilities), 0 = выкл, 1+ = вкл - */ - private static ?int $enabled = null; + /** + * Настройка: включено ли сохранение клиентских логов. + * Соответствует настройке client_logs_save из $rSettings. + * + * @var int|null null = не задано (проверяем через StreamingUtilities), 0 = выкл, 1+ = вкл + */ + private static ?int $enabled = null; - /** - * Установить путь к файлу лога. - * - * @param string $path Полный путь к файлу - */ - public static function setLogFile(string $path): void { - self::$logFile = $path; - } + /** + * Установить путь к файлу лога. + * + * @param string $path Полный путь к файлу + */ + public static function setLogFile(string $path): void { + self::$logFile = $path; + } - /** - * Установить, включено ли логирование клиентских запросов. - * - * @param int $value 0 — выключено, 1+ — включено - */ - public static function setEnabled(int $value): void { - self::$enabled = $value; - } + /** + * Установить, включено ли логирование клиентских запросов. + * + * @param int $value 0 — выключено, 1+ — включено + */ + public static function setEnabled(int $value): void { + self::$enabled = $value; + } - /** - * Получить текущий путь к файлу лога. - * - * @return string - */ - public static function getLogFile(): string { - if (self::$logFile !== null) { - return self::$logFile; - } + /** + * Получить текущий путь к файлу лога. + * + * @return string + */ + public static function getLogFile(): string { + if (self::$logFile !== null) { + return self::$logFile; + } - if (defined('LOGS_TMP_PATH')) { - return LOGS_TMP_PATH . 'client_request.log'; - } + if (defined('LOGS_TMP_PATH')) { + return LOGS_TMP_PATH . 'client_request.log'; + } - return '/tmp/xc_vm_client.log'; - } + return '/tmp/xc_vm_client.log'; + } - /** - * Записать клиентское событие стриминга. - * - * Соответствует старому формату StreamingUtilities::clientLog(). - * Сигнатура через LoggerInterface, но внутренне хранит расширенные данные. - * - * @param string $type Действие (AUTH_FAILED, USER_EXPIRED, и т.д.) - * @param string $message Не используется для клиентских логов (передавать '') - * @param string|int $extra Дополнительные данные - * @param int $line Не используется (совместимость с LoggerInterface) - */ - public static function log(string $type, string $message, $extra = '', int $line = 0): void { - // По умолчанию логирование включено (если не задано явно) - if (self::$enabled !== null && self::$enabled == 0) { - return; - } + /** + * Записать клиентское событие стриминга. + * + * Соответствует старому формату StreamingUtilities::clientLog(). + * Сигнатура через LoggerInterface, но внутренне хранит расширенные данные. + * + * @param string $type Действие (AUTH_FAILED, USER_EXPIRED, и т.д.) + * @param string $message Не используется для клиентских логов (передавать '') + * @param string|int $extra Дополнительные данные + * @param int $line Не используется (совместимость с LoggerInterface) + */ + public static function log(string $type, string $message, string|int $extra = '', int $line = 0): void { + // По умолчанию логирование включено (если не задано явно) + if (self::$enabled !== null && self::$enabled == 0) { + return; + } - $rUserAgent = (!empty($_SERVER['HTTP_USER_AGENT']) - ? htmlentities($_SERVER['HTTP_USER_AGENT']) - : ''); + $rUserAgent = (!empty($_SERVER['HTTP_USER_AGENT']) + ? htmlentities($_SERVER['HTTP_USER_AGENT']) + : ''); - $rQueryString = (!empty($_SERVER['QUERY_STRING']) - ? htmlentities($_SERVER['QUERY_STRING']) - : ''); + $rQueryString = (!empty($_SERVER['QUERY_STRING']) + ? htmlentities($_SERVER['QUERY_STRING']) + : ''); - $rData = [ - 'user_id' => 0, - 'stream_id' => 0, - 'action' => $type, - 'query_string' => $rQueryString, - 'user_agent' => $rUserAgent, - 'user_ip' => '', - 'time' => time(), - 'extra_data' => (string) $extra, - ]; + $rData = [ + 'user_id' => 0, + 'stream_id' => 0, + 'action' => $type, + 'query_string' => $rQueryString, + 'user_agent' => $rUserAgent, + 'user_ip' => '', + 'time' => time(), + 'extra_data' => (string) $extra, + ]; - file_put_contents( - self::getLogFile(), - base64_encode(json_encode($rData, JSON_UNESCAPED_UNICODE)) . "\n", - FILE_APPEND - ); - } + file_put_contents( + self::getLogFile(), + base64_encode(json_encode($rData, JSON_UNESCAPED_UNICODE)) . "\n", + FILE_APPEND + ); + } - /** - * Записать клиентское событие стриминга (расширенная версия). - * - * Прямой аналог StreamingUtilities::clientLog() с полными параметрами. - * - * @param int $streamID ID потока - * @param int $userID ID пользователя - * @param string $action Действие (AUTH_FAILED, USER_EXPIRED, и т.д.) - * @param string $ip IP-адрес клиента - * @param string $data Дополнительные данные (JSON или строка) - * @param bool $bypass Записать даже если логирование выключено - */ - public static function clientLog( - int $streamID, - int $userID, - string $action, - string $ip, - string $data = '', - bool $bypass = false - ): void { - // Проверяем настройку: включено ли логирование - if (!$bypass) { - // Если задано через setEnabled - if (self::$enabled !== null && self::$enabled == 0) { - return; - } + /** + * Записать клиентское событие стриминга (расширенная версия). + * + * Прямой аналог StreamingUtilities::clientLog() с полными параметрами. + * + * @param int $streamID ID потока + * @param int $userID ID пользователя + * @param string $action Действие (AUTH_FAILED, USER_EXPIRED, и т.д.) + * @param string $ip IP-адрес клиента + * @param string $data Дополнительные данные (JSON или строка) + * @param bool $bypass Записать даже если логирование выключено + */ + public static function clientLog( + int $streamID, + int $userID, + string $action, + string $ip, + string $data = '', + bool $bypass = false + ): void { + // Проверяем настройку: включено ли логирование + if (!$bypass) { + // Если задано через setEnabled + if (self::$enabled !== null && self::$enabled == 0) { + return; + } - // Если не задано — проверяем через глобальные настройки (обратная совместимость) - if (self::$enabled === null && !empty($GLOBALS['rSettings'])) { - if ( - isset($GLOBALS['rSettings']['client_logs_save']) - && $GLOBALS['rSettings']['client_logs_save'] == 0 - ) { - return; - } - } - } + // Если не задано — проверяем через глобальные настройки (обратная совместимость) + if (self::$enabled === null && !empty($GLOBALS['rSettings'])) { + if ( + isset($GLOBALS['rSettings']['client_logs_save']) + && $GLOBALS['rSettings']['client_logs_save'] == 0 + ) { + return; + } + } + } - $rUserAgent = (!empty($_SERVER['HTTP_USER_AGENT']) - ? htmlentities($_SERVER['HTTP_USER_AGENT']) - : ''); + $rUserAgent = (!empty($_SERVER['HTTP_USER_AGENT']) + ? htmlentities($_SERVER['HTTP_USER_AGENT']) + : ''); - $rQueryString = (!empty($_SERVER['QUERY_STRING']) - ? htmlentities($_SERVER['QUERY_STRING']) - : ''); + $rQueryString = (!empty($_SERVER['QUERY_STRING']) + ? htmlentities($_SERVER['QUERY_STRING']) + : ''); - $rData = [ - 'user_id' => $userID, - 'stream_id' => $streamID, - 'action' => $action, - 'query_string' => $rQueryString, - 'user_agent' => $rUserAgent, - 'user_ip' => $ip, - 'time' => time(), - 'extra_data' => $data, - ]; + $rData = [ + 'user_id' => $userID, + 'stream_id' => $streamID, + 'action' => $action, + 'query_string' => $rQueryString, + 'user_agent' => $rUserAgent, + 'user_ip' => $ip, + 'time' => time(), + 'extra_data' => $data, + ]; - file_put_contents( - self::getLogFile(), - base64_encode(json_encode($rData, JSON_UNESCAPED_UNICODE)) . "\n", - FILE_APPEND - ); - } + file_put_contents( + self::getLogFile(), + base64_encode(json_encode($rData, JSON_UNESCAPED_UNICODE)) . "\n", + FILE_APPEND + ); + } } diff --git a/src/Core/Logging/FileLogger.php b/src/Core/Logging/FileLogger.php index 3eb4b3db..b9698bc4 100644 --- a/src/Core/Logging/FileLogger.php +++ b/src/Core/Logging/FileLogger.php @@ -27,111 +27,111 @@ namespace XcVm\Core\Logging; require_once __DIR__ . '/LoggerInterface.php'; class FileLogger implements LoggerInterface { - /** - * Путь к файлу лога. - * По умолчанию используется LOGS_TMP_PATH . 'error_log.log' - * - * @var string|null - */ - private static ?string $logFile = null; + /** + * Путь к файлу лога. + * По умолчанию используется LOGS_TMP_PATH . 'error_log.log' + * + * @var string|null + */ + private static ?string $logFile = null; - /** - * Установить путь к файлу лога. - * - * @param string $path Полный путь к файлу - */ - public static function setLogFile(string $path): void { - self::$logFile = $path; - } + /** + * Установить путь к файлу лога. + * + * @param string $path Полный путь к файлу + */ + public static function setLogFile(string $path): void { + self::$logFile = $path; + } - /** - * Получить текущий путь к файлу лога. - * Если не установлен явно, используется LOGS_TMP_PATH . 'error_log.log' - * - * @return string - */ - public static function getLogFile(): string { - if (self::$logFile !== null) { - return self::$logFile; - } + /** + * Получить текущий путь к файлу лога. + * Если не установлен явно, используется LOGS_TMP_PATH . 'error_log.log' + * + * @return string + */ + public static function getLogFile(): string { + if (self::$logFile !== null) { + return self::$logFile; + } - if (defined('LOGS_TMP_PATH')) { - return LOGS_TMP_PATH . 'error_log.log'; - } + if (defined('LOGS_TMP_PATH')) { + return LOGS_TMP_PATH . 'error_log.log'; + } - return '/tmp/xc_vm_error.log'; - } + return '/tmp/xc_vm_error.log'; + } - /** - * Записать лог-сообщение в файл. - * - * Перед записью проверяется фильтр: шумные ошибки MySQL и рекурсивные - * обращения к panel_logs игнорируются. - * - * @param string $type Тип события ('pdo', 'epg', 'error', и т.д.) - * @param string $message Текст сообщения - * @param string|int $extra Дополнительные данные (SQL-запрос, trace, и т.д.) - * @param int $line Номер строки (опционально) - */ - public static function log(string $type, string $message, $extra = '', int $line = 0): void { - $extra = (string) $extra; + /** + * Записать лог-сообщение в файл. + * + * Перед записью проверяется фильтр: шумные ошибки MySQL и рекурсивные + * обращения к panel_logs игнорируются. + * + * @param string $type Тип события ('pdo', 'epg', 'error', и т.д.) + * @param string $message Текст сообщения + * @param string|int $extra Дополнительные данные (SQL-запрос, trace, и т.д.) + * @param int $line Номер строки (опционально) + */ + public static function log(string $type, string $message, string|int $extra = '', int $line = 0): void { + $extra = (string) $extra; - $rTrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); - $rCaller = $rTrace[1] ?? []; - $rFile = (string) ($rCaller['file'] ?? ''); - if ($line <= 0 && isset($rCaller['line'])) { - $line = (int) $rCaller['line']; - } + $rTrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + $rCaller = $rTrace[1] ?? []; + $rFile = (string) ($rCaller['file'] ?? ''); + if ($line <= 0 && isset($rCaller['line'])) { + $line = (int) $rCaller['line']; + } - // Фильтрация шумных / рекурсивных записей - if (self::shouldSkip($message, $extra)) { - return; - } + // Фильтрация шумных / рекурсивных записей + if (self::shouldSkip($message, $extra)) { + return; + } - $rData = [ - 'type' => $type, - 'message' => $message, - 'extra' => $extra, - 'file' => $rFile, - 'line' => $line, - 'time' => time(), - 'env' => php_sapi_name(), - 'server_id' => defined('SERVER_ID') ? SERVER_ID : null, - ]; + $rData = [ + 'type' => $type, + 'message' => $message, + 'extra' => $extra, + 'file' => $rFile, + 'line' => $line, + 'time' => time(), + 'env' => php_sapi_name(), + 'server_id' => defined('SERVER_ID') ? SERVER_ID : null, + ]; - $logLine = base64_encode(json_encode($rData, JSON_UNESCAPED_UNICODE)) . "\n"; + $logLine = base64_encode(json_encode($rData, JSON_UNESCAPED_UNICODE)) . "\n"; - $rFile = self::getLogFile(); - $rDir = dirname($rFile); - if (!is_dir($rDir)) { - @mkdir($rDir, 0775, true); - } - file_put_contents($rFile, $logLine, FILE_APPEND | LOCK_EX); - } + $rFile = self::getLogFile(); + $rDir = dirname($rFile); + if (!is_dir($rDir)) { + @mkdir($rDir, 0775, true); + } + file_put_contents($rFile, $logLine, FILE_APPEND | LOCK_EX); + } - /** - * Проверить, нужно ли пропустить запись (фильтрация шума). - * - * @param string $message Текст сообщения - * @param string $extra Дополнительные данные - * - * @return bool true — пропустить, false — записать - */ - private static function shouldSkip(string $message, string $extra): bool { - // Рекурсивный лог: запись о таблице panel_logs - if (stripos($extra, 'panel_logs') !== false) { - return true; - } + /** + * Проверить, нужно ли пропустить запись (фильтрация шума). + * + * @param string $message Текст сообщения + * @param string $extra Дополнительные данные + * + * @return bool true — пропустить, false — записать + */ + private static function shouldSkip(string $message, string $extra): bool { + // Рекурсивный лог: запись о таблице panel_logs + if (stripos($extra, 'panel_logs') !== false) { + return true; + } - // Шумные ошибки MySQL — пропускаем - $noisy = ['timeout exceeded', 'lock wait timeout', 'duplicate entry']; - $messageLower = strtolower($message); - foreach ($noisy as $pattern) { - if (strpos($messageLower, $pattern) !== false) { - return true; - } - } + // Шумные ошибки MySQL — пропускаем + $noisy = ['timeout exceeded', 'lock wait timeout', 'duplicate entry']; + $messageLower = strtolower($message); + foreach ($noisy as $pattern) { + if (strpos($messageLower, $pattern) !== false) { + return true; + } + } - return false; - } + return false; + } } diff --git a/src/Core/Logging/Logger.php b/src/Core/Logging/Logger.php index f932dec3..18fd3458 100644 --- a/src/Core/Logging/Logger.php +++ b/src/Core/Logging/Logger.php @@ -14,282 +14,282 @@ namespace XcVm\Core\Logging; */ final class Logger { - /** @var bool Whether errors should be displayed on screen */ - private static bool $showErrors = false; + /** @var bool Whether errors should be displayed on screen */ + private static bool $showErrors = false; - /** @var string Full path to the log file */ - private static string $logFile; + /** @var string Full path to the log file */ + private static string $logFile; - /** - * Initializes error, exception, and fatal error handlers. - * - * @param bool $showErrors true to display errors, false to hide UI output - * @param string $logFile Full path to the file where logs will be written - */ - public static function init(bool $showErrors, string $logFile): void { - self::$showErrors = $showErrors; - self::$logFile = $logFile; + /** + * Initializes error, exception, and fatal error handlers. + * + * @param bool $showErrors true to display errors, false to hide UI output + * @param string $logFile Full path to the file where logs will be written + */ + public static function init(bool $showErrors, string $logFile): void { + self::$showErrors = $showErrors; + self::$logFile = $logFile; - // Set custom handlers - set_error_handler([self::class, 'handleError']); - set_exception_handler([self::class, 'handleException']); - register_shutdown_function([self::class, 'handleFatal']); + // Set custom handlers + set_error_handler([self::class, 'handleError']); + set_exception_handler([self::class, 'handleException']); + register_shutdown_function([self::class, 'handleFatal']); - // Always collect all errors for logging; UI visibility is controlled separately. - error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED); + // Always collect all errors for logging; UI visibility is controlled separately. + error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED); - if ($showErrors) { - ini_set('display_errors', '1'); - ini_set('display_startup_errors', '1'); - } else { - ini_set('display_errors', '0'); - ini_set('display_startup_errors', '0'); - } - } + if ($showErrors) { + ini_set('display_errors', '1'); + ini_set('display_startup_errors', '1'); + } else { + ini_set('display_errors', '0'); + ini_set('display_startup_errors', '0'); + } + } - /* ================= ERRORS ================= */ + /* ================= ERRORS ================= */ - /** - * Handler for regular PHP errors (trigger_error, warnings, notices, etc.). - * - * @param int $errno Error level (E_WARNING, E_NOTICE, etc.) - * @param string $message Error message - * @param string $file File where the error occurred - * @param int $line Line number in the file - * - * @return bool true if the error was handled (prevents default PHP handler) - */ - public static function handleError( - int $errno, - string $message, - string $file, - int $line - ): bool { - // Ignore suppressed errors (using @ operator) - if (!(error_reporting() & $errno)) { - return false; - } + /** + * Handler for regular PHP errors (trigger_error, warnings, notices, etc.). + * + * @param int $errno Error level (E_WARNING, E_NOTICE, etc.) + * @param string $message Error message + * @param string $file File where the error occurred + * @param int $line Line number in the file + * + * @return bool true if the error was handled (prevents default PHP handler) + */ + public static function handleError( + int $errno, + string $message, + string $file, + int $line + ): bool { + // Ignore suppressed errors (using @ operator) + if (!(error_reporting() & $errno)) { + return false; + } - self::log( - self::mapErrorLevel($errno), - $message, - self::buildTrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)), - $file, - $line - ); + self::log( + self::mapErrorLevel($errno), + $message, + self::buildTrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)), + $file, + $line + ); - return true; - } + return true; + } - /* ================= EXCEPTIONS ================= */ + /* ================= EXCEPTIONS ================= */ - /** - * Handler for uncaught exceptions. - * - * @param \Throwable $e The uncaught exception - */ - public static function handleException(\Throwable $e): void { - self::log( - 'EXCEPTION', - $e->getMessage(), - self::buildExceptionTrace($e), - $e->getFile(), - $e->getLine() - ); - } + /** + * Handler for uncaught exceptions. + * + * @param \Throwable $e The uncaught exception + */ + public static function handleException(\Throwable $e): void { + self::log( + 'EXCEPTION', + $e->getMessage(), + self::buildExceptionTrace($e), + $e->getFile(), + $e->getLine() + ); + } - /* ================= FATAL ================= */ + /* ================= FATAL ================= */ - /** - * Handler for fatal errors (E_ERROR, E_PARSE, etc.) that terminate script execution. - * Called via register_shutdown_function. - */ - public static function handleFatal(): void { - $error = error_get_last(); + /** + * Handler for fatal errors (E_ERROR, E_PARSE, etc.) that terminate script execution. + * Called via register_shutdown_function. + */ + public static function handleFatal(): void { + $error = error_get_last(); - // Process only true fatal errors - if ($error && in_array($error['type'], [ - E_ERROR, - E_PARSE, - E_CORE_ERROR, - E_COMPILE_ERROR - ], true)) { - self::log( - 'FATAL', - $error['message'], - '', // Stack trace is not available for fatal errors - $error['file'], - $error['line'] - ); - } - } + // Process only true fatal errors + if ($error && in_array($error['type'], [ + E_ERROR, + E_PARSE, + E_CORE_ERROR, + E_COMPILE_ERROR + ], true)) { + self::log( + 'FATAL', + $error['message'], + '', // Stack trace is not available for fatal errors + $error['file'], + $error['line'] + ); + } + } - /* ================= CORE LOG ================= */ + /* ================= CORE LOG ================= */ - /** - * Core logging method. Writes the event to the log file and optionally outputs it on screen. - * - * @param string $type Event type (ERROR, WARNING, NOTICE, EXCEPTION, FATAL, etc.) - * @param string $message Message text - * @param string $trace Stack trace (optional) - * @param string $file File where the event occurred - * @param int $line Line number - */ - public static function log( - string $type, - string $message, - string $trace = '', - string $file = '', - int $line = 0 - ): void { - $data = [ - 'type' => $type, - 'log_message' => $message, - 'file' => $file, - 'line' => $line, - 'log_extra' => $trace, // Process ID - 'time' => time(), // Unix timestamp - 'env' => php_sapi_name(), // SAPI name (cli, fpm-fcgi, etc.) - 'version' => defined('XC_VM_VERSION') ? XC_VM_VERSION : 'unknown', - ]; + /** + * Core logging method. Writes the event to the log file and optionally outputs it on screen. + * + * @param string $type Event type (ERROR, WARNING, NOTICE, EXCEPTION, FATAL, etc.) + * @param string $message Message text + * @param string $trace Stack trace (optional) + * @param string $file File where the event occurred + * @param int $line Line number + */ + public static function log( + string $type, + string $message, + string $trace = '', + string $file = '', + int $line = 0 + ): void { + $data = [ + 'type' => $type, + 'log_message' => $message, + 'file' => $file, + 'line' => $line, + 'log_extra' => $trace, // Process ID + 'time' => time(), // Unix timestamp + 'env' => php_sapi_name(), // SAPI name (cli, fpm-fcgi, etc.) + 'version' => defined('XC_VM_VERSION') ? XC_VM_VERSION : 'unknown', + ]; - // Ensure log directory exists - $logDir = dirname(self::$logFile); - if (!is_dir($logDir)) { - @mkdir($logDir, 0775, true); - } + // Ensure log directory exists + $logDir = dirname(self::$logFile); + if (!is_dir($logDir)) { + @mkdir($logDir, 0775, true); + } - // Write to file as base64-encoded JSON (easy to parse and prevents line corruption) - file_put_contents( - self::$logFile, - base64_encode(json_encode($data, JSON_UNESCAPED_UNICODE)) . "\n", - FILE_APPEND | LOCK_EX - ); + // Write to file as base64-encoded JSON (easy to parse and prevents line corruption) + file_put_contents( + self::$logFile, + base64_encode(json_encode($data, JSON_UNESCAPED_UNICODE)) . "\n", + FILE_APPEND | LOCK_EX + ); - // Set log file permissions if running as root (common in containers) - if (function_exists('posix_geteuid') && posix_geteuid() === 0) { - @chown(self::$logFile, 'xc_vm'); - @chgrp(self::$logFile, 'xc_vm'); - @chmod(self::$logFile, 0664); - } + // Set log file permissions if running as root (common in containers) + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + @chown(self::$logFile, 'xc_vm'); + @chgrp(self::$logFile, 'xc_vm'); + @chmod(self::$logFile, 0664); + } - // If UI output is enabled, display a readable message on screen - if (self::$showErrors) { - self::output($data); - } - } + // If UI output is enabled, display a readable message on screen + if (self::$showErrors) { + self::output($data); + } + } - /* ================= TRACE ================= */ + /* ================= TRACE ================= */ - /** - * Builds a stack trace for an exception chain (including previous exceptions). - * - * @param \Throwable $e The exception - * - * @return string Formatted trace string - */ - private static function buildExceptionTrace(\Throwable $e): string { - $out = []; + /** + * Builds a stack trace for an exception chain (including previous exceptions). + * + * @param \Throwable $e The exception + * + * @return string Formatted trace string + */ + private static function buildExceptionTrace(\Throwable $e): string { + $out = []; - do { - // Main exception info - $out[] = sprintf( - "%s: %s in %s:%d", - get_class($e), - $e->getMessage(), - $e->getFile(), - $e->getLine() - ); + do { + // Main exception info + $out[] = sprintf( + "%s: %s in %s:%d", + get_class($e), + $e->getMessage(), + $e->getFile(), + $e->getLine() + ); - // Call stack for the current exception - foreach ($e->getTrace() as $i => $t) { - $out[] = sprintf( - "#%d %s(%s): %s()", - $i, - $t['file'] ?? '[internal]', - $t['line'] ?? '?', - $t['function'] - ); - } + // Call stack for the current exception + foreach ($e->getTrace() as $i => $t) { + $out[] = sprintf( + "#%d %s(%s): %s()", + $i, + $t['file'] ?? '[internal]', + $t['line'] ?? '?', + $t['function'] + ); + } - $e = $e->getPrevious(); - if ($e) { - $out[] = "---- CAUSED BY ----"; - } - } while ($e); + $e = $e->getPrevious(); + if ($e) { + $out[] = "---- CAUSED BY ----"; + } + } while ($e); - return implode("\n", $out); - } + return implode("\n", $out); + } - /** - * Builds a stack trace from the array returned by debug_backtrace(). - * - * @param array $trace Backtrace array - * - * @return string Formatted trace string - */ - private static function buildTrace(array $trace): string { - $out = []; + /** + * Builds a stack trace from the array returned by debug_backtrace(). + * + * @param array $trace Backtrace array + * + * @return string Formatted trace string + */ + private static function buildTrace(array $trace): string { + $out = []; - foreach ($trace as $i => $t) { - $out[] = sprintf( - "#%d %s(%s): %s()", - $i, - $t['file'] ?? '[internal]', - $t['line'] ?? '?', - $t['function'] - ); - } + foreach ($trace as $i => $t) { + $out[] = sprintf( + "#%d %s(%s): %s()", + $i, + $t['file'] ?? '[internal]', + $t['line'] ?? '?', + $t['function'] + ); + } - return implode("\n", $out); - } + return implode("\n", $out); + } - /* ================= HELPERS ================= */ + /* ================= HELPERS ================= */ - /** - * Maps a PHP error constant to a human-readable string. - * - * @param int $errno Error level constant - * - * @return string Error type (ERROR, WARNING, NOTICE, INFO) - */ - private static function mapErrorLevel(int $errno): string { - return match ($errno) { - E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR => 'ERROR', - E_WARNING, E_USER_WARNING => 'WARNING', - E_NOTICE, E_USER_NOTICE => 'NOTICE', - default => 'INFO', - }; - } + /** + * Maps a PHP error constant to a human-readable string. + * + * @param int $errno Error level constant + * + * @return string Error type (ERROR, WARNING, NOTICE, INFO) + */ + private static function mapErrorLevel(int $errno): string { + return match ($errno) { + E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR => 'ERROR', + E_WARNING, E_USER_WARNING => 'WARNING', + E_NOTICE, E_USER_NOTICE => 'NOTICE', + default => 'INFO', + }; + } - /** - * Outputs error/exception information to the screen in a readable format. - * Format depends on the environment (CLI or Web). - * - * @param array $d Event data (from the log method) - */ - private static function output(array $d): void { - if (php_sapi_name() === 'cli') { - // Color scheme for terminal - $color = match ($d['type']) { - 'FATAL', 'ERROR' => "\033[41m\033[97m", // red background, white text - 'WARNING' => "\033[43m\033[30m", // yellow background, black text - 'NOTICE' => "\033[44m\033[97m", // blue background, white text - default => "\033[45m\033[97m", // magenta background, white text - }; + /** + * Outputs error/exception information to the screen in a readable format. + * Format depends on the environment (CLI or Web). + * + * @param array $d Event data (from the log method) + */ + private static function output(array $d): void { + if (php_sapi_name() === 'cli') { + // Color scheme for terminal + $color = match ($d['type']) { + 'FATAL', 'ERROR' => "\033[41m\033[97m", // red background, white text + 'WARNING' => "\033[43m\033[30m", // yellow background, black text + 'NOTICE' => "\033[44m\033[97m", // blue background, white text + default => "\033[45m\033[97m", // magenta background, white text + }; - echo "\n{$color} {$d['type']} \033[0m "; - echo date('Y-m-d H:i:s', $d['time']) . "\n"; - echo "{$d['log_message']}\n"; - echo "{$d['file']}:{$d['line']}\n"; + echo "\n{$color} {$d['type']} \033[0m "; + echo date('Y-m-d H:i:s', $d['time']) . "\n"; + echo "{$d['log_message']}\n"; + echo "{$d['file']}:{$d['line']}\n"; - if (!empty($d['log_extra'])) { - echo str_repeat('-', 60) . "\n"; - echo $d['log_extra'] . "\n"; - } - } else { - // Web environment output - echo "
"; - echo "{$d['type']} "; - echo "" . date('Y-m-d H:i:s', $d['time']) . "
"; - echo htmlspecialchars($d['log_message'], ENT_SUBSTITUTE) . "
"; - echo "{$d['file']}:{$d['line']}"; + echo "{$d['type']} "; + echo "" . date('Y-m-d H:i:s', $d['time']) . "
"; + echo htmlspecialchars($d['log_message'], ENT_SUBSTITUTE) . "
"; + echo "{$d['file']}:{$d['line']}"; - if (!empty($d['log_extra'])) { - echo "
";
-                echo htmlspecialchars($d['log_extra'], ENT_SUBSTITUTE);
-                echo "
"; - } - echo "
"; - } - } + if (!empty($d['log_extra'])) { + echo "
";
+				echo htmlspecialchars($d['log_extra'], ENT_SUBSTITUTE);
+				echo "
"; + } + echo ""; + } + } } diff --git a/src/Core/Logging/LoggerInterface.php b/src/Core/Logging/LoggerInterface.php index bfe07dba..f826387c 100644 --- a/src/Core/Logging/LoggerInterface.php +++ b/src/Core/Logging/LoggerInterface.php @@ -19,15 +19,15 @@ namespace XcVm\Core\Logging; */ interface LoggerInterface { - /** - * Записать лог-сообщение. - * - * @param string $type Тип события (например: 'pdo', 'epg', 'AUTH_FAILED') - * @param string $message Текст сообщения - * @param string|int $extra Дополнительные данные (trace, query, и т.д.) - * @param int $line Номер строки (опционально) - * - * @return void - */ - public static function log(string $type, string $message, $extra = '', int $line = 0): void; + /** + * Записать лог-сообщение. + * + * @param string $type Тип события (например: 'pdo', 'epg', 'AUTH_FAILED') + * @param string $message Текст сообщения + * @param string|int $extra Дополнительные данные (trace, query, и т.д.) + * @param int $line Номер строки (опционально) + * + * @return void + */ + public static function log(string $type, string $message, string|int $extra = '', int $line = 0): void; } diff --git a/src/Core/Logging/UpdateLogger.php b/src/Core/Logging/UpdateLogger.php index e99087bd..998d1163 100644 --- a/src/Core/Logging/UpdateLogger.php +++ b/src/Core/Logging/UpdateLogger.php @@ -18,7 +18,6 @@ namespace XcVm\Core\Logging; */ class UpdateLogger { - /** * Path to the update log file (outside tmp/ so cron cleanup won't delete it). * diff --git a/src/Core/Module/BaseModule.php b/src/Core/Module/BaseModule.php index ca0dc157..aa5e28f5 100644 --- a/src/Core/Module/BaseModule.php +++ b/src/Core/Module/BaseModule.php @@ -27,123 +27,128 @@ use XcVm\Core\Module\Contract\TopbarProviderInterface; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ abstract class BaseModule implements ModuleInterface, MigratableInterface, CronProviderInterface, TopbarProviderInterface, TableProviderInterface, PermissionProviderInterface, QuickToolsProviderInterface { + /** + * Unique module identifier. + * + * @return string + */ + abstract public function getName(): string; - /** - * Unique module identifier. - * - * @return string - */ - abstract public function getName(): string; + /** + * Module version string. + * + * @return string + */ + abstract public function getVersion(): string; - /** - * Module version string. - * - * @return string - */ - abstract public function getVersion(): string; + /** + * Boot hook: register services/bindings into the container. No-op by default. + * + * @param ServiceContainer $container The DI container. + * @return void + */ + public function boot(ServiceContainer $container): void { + } - /** - * Boot hook: register services/bindings into the container. No-op by default. - * - * @param ServiceContainer $container The DI container. - * @return void - */ - public function boot(ServiceContainer $container): void {} + /** + * Event subscribers provided by the module. Empty by default. + * + * @return array Map/list of event subscribers. + */ + public function getEventSubscribers(): array { + return []; + } - /** - * Event subscribers provided by the module. Empty by default. - * - * @return array Map/list of event subscribers. - */ - public function getEventSubscribers(): array { - return []; - } + /** + * Register the module's HTTP routes. No-op by default. + * + * @param Router $router The application router. + * @return void + */ + public function registerRoutes(Router $router): void { + } - /** - * Register the module's HTTP routes. No-op by default. - * - * @param Router $router The application router. - * @return void - */ - public function registerRoutes(Router $router): void {} + /** + * Register the module's CLI commands. No-op by default. + * + * @param CommandRegistry $registry The CLI command registry. + * @return void + */ + public function registerCommands(CommandRegistry $registry): void { + } - /** - * Register the module's CLI commands. No-op by default. - * - * @param CommandRegistry $registry The CLI command registry. - * @return void - */ - public function registerCommands(CommandRegistry $registry): void {} + /** + * Register the module's navbar entries. No-op by default. + * + * @param NavbarRegistry $registry The navbar registry. + * @return void + */ + public function registerNavbar(NavbarRegistry $registry): void { + } - /** - * Register the module's navbar entries. No-op by default. - * - * @param NavbarRegistry $registry The navbar registry. - * @return void - */ - public function registerNavbar(NavbarRegistry $registry): void {} + /** + * No-op default — override to contribute per-page topbar buttons. + * + * @return void + */ + public function registerTopbar(TopbarRegistry $registry): void { + } - /** - * No-op default — override to contribute per-page topbar buttons. - * - * @param TopbarRegistry $registry - * @return void - */ - public function registerTopbar(TopbarRegistry $registry): void {} + /** + * No-op default — override to register serverSide DataTable handlers. + * + * @return void + */ + public function registerTables(TableRegistry $registry): void { + } - /** - * No-op default — override to register serverSide DataTable handlers. - * - * @param TableRegistry $registry - * @return void - */ - public function registerTables(TableRegistry $registry): void {} + /** + * No-op default — override to register reseller sub-permission keys. + * + * @return void + */ + public function registerPermissions(PermissionRegistry $registry): void { + } - /** - * No-op default — override to register reseller sub-permission keys. - * - * @param PermissionRegistry $registry - * @return void - */ - public function registerPermissions(PermissionRegistry $registry): void {} + /** + * No-op default — override to register one-shot Quick Tools actions. + * + * @return void + */ + public function registerQuickTools(QuickToolsRegistry $registry): void { + } - /** - * No-op default — override to register one-shot Quick Tools actions. - * - * @param QuickToolsRegistry $registry - * @return void - */ - public function registerQuickTools(QuickToolsRegistry $registry): void {} + /** + * Installation hook, run when the module is installed. No-op by default. + * + * @return void + */ + public function install(): void { + } - /** - * Installation hook, run when the module is installed. No-op by default. - * - * @return void - */ - public function install(): void {} + /** + * Uninstallation hook, run when the module is removed. No-op by default. + * + * @return void + */ + public function uninstall(): void { + } - /** - * Uninstallation hook, run when the module is removed. No-op by default. - * - * @return void - */ - public function uninstall(): void {} + /** + * Database migrations provided by the module. Empty by default. + * + * @return array Migration descriptors. + */ + public function getMigrations(): array { + return []; + } - /** - * Database migrations provided by the module. Empty by default. - * - * @return array Migration descriptors. - */ - public function getMigrations(): array { - return []; - } - - /** - * Cron entries provided by the module. Empty by default. - * - * @return array Cron entry descriptors. - */ - public function getCronEntries(): array { - return []; - } + /** + * Cron entries provided by the module. Empty by default. + * + * @return array Cron entry descriptors. + */ + public function getCronEntries(): array { + return []; + } } diff --git a/src/Core/Module/Contract/CommandProviderInterface.php b/src/Core/Module/Contract/CommandProviderInterface.php index 01a95044..3710fb46 100644 --- a/src/Core/Module/Contract/CommandProviderInterface.php +++ b/src/Core/Module/Contract/CommandProviderInterface.php @@ -11,14 +11,12 @@ use XcVm\Cli\CommandRegistry; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface CommandProviderInterface { - - /** - * Register CLI commands and cron jobs for this module. - * - * Module explicitly instantiates and registers CommandInterface instances. - * No filesystem scanning — all registration is explicit PHP. - * - * @param CommandRegistry $registry - */ - public function registerCommands(CommandRegistry $registry): void; + /** + * Register CLI commands and cron jobs for this module. + * + * Module explicitly instantiates and registers CommandInterface instances. + * No filesystem scanning — all registration is explicit PHP. + * + */ + public function registerCommands(CommandRegistry $registry): void; } diff --git a/src/Core/Module/Contract/CronProviderInterface.php b/src/Core/Module/Contract/CronProviderInterface.php index b5de7c4f..9bb23dd5 100644 --- a/src/Core/Module/Contract/CronProviderInterface.php +++ b/src/Core/Module/Contract/CronProviderInterface.php @@ -19,19 +19,18 @@ namespace XcVm\Core\Module\Contract; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface CronProviderInterface { - - /** - * Return the cron schedule entries this module requires. - * - * Keys are standard cron time expressions; values are console command names. - * The caller (StartupCommand / StatusCommand) resolves PHP_BIN, MAIN_HOME, - * and the comment suffix automatically. - * - * Example: - * return ['* * * * *' => 'cron:watch']; - * // Produces: "* * * * * /usr/bin/php /opt/xc_vm/console.php cron:watch # XC_VM" - * - * @return array cron-expression => console-command-name - */ - public function getCronEntries(): array; + /** + * Return the cron schedule entries this module requires. + * + * Keys are standard cron time expressions; values are console command names. + * The caller (StartupCommand / StatusCommand) resolves PHP_BIN, MAIN_HOME, + * and the comment suffix automatically. + * + * Example: + * return ['* * * * *' => 'cron:watch']; + * // Produces: "* * * * * /usr/bin/php /opt/xc_vm/console.php cron:watch # XC_VM" + * + * @return array cron-expression => console-command-name + */ + public function getCronEntries(): array; } diff --git a/src/Core/Module/Contract/NavbarProviderInterface.php b/src/Core/Module/Contract/NavbarProviderInterface.php index 8ff4dc76..01111ac6 100644 --- a/src/Core/Module/Contract/NavbarProviderInterface.php +++ b/src/Core/Module/Contract/NavbarProviderInterface.php @@ -11,12 +11,11 @@ use XcVm\Core\Module\NavbarRegistry; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface NavbarProviderInterface { - - /** - * Register navbar items via the provided NavbarRegistry. - * - * Called in bootAll() after all modules have been booted. - * Use $registry->add() instead of NavbarRegistry::add() (static). - */ - public function registerNavbar(NavbarRegistry $registry): void; + /** + * Register navbar items via the provided NavbarRegistry. + * + * Called in bootAll() after all modules have been booted. + * Use $registry->add() instead of NavbarRegistry::add() (static). + */ + public function registerNavbar(NavbarRegistry $registry): void; } diff --git a/src/Core/Module/Contract/PermissionProviderInterface.php b/src/Core/Module/Contract/PermissionProviderInterface.php index edd58fb8..021614e2 100644 --- a/src/Core/Module/Contract/PermissionProviderInterface.php +++ b/src/Core/Module/Contract/PermissionProviderInterface.php @@ -11,12 +11,11 @@ use XcVm\Core\Module\PermissionRegistry; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface PermissionProviderInterface { - - /** - * Register reseller sub-permission keys via the provided PermissionRegistry. - * - * Called in bootAll() (same phase as the other providers). The keys appear - * in the group editor's permission catalogue with `permission_` labels. - */ - public function registerPermissions(PermissionRegistry $registry): void; + /** + * Register reseller sub-permission keys via the provided PermissionRegistry. + * + * Called in bootAll() (same phase as the other providers). The keys appear + * in the group editor's permission catalogue with `permission_` labels. + */ + public function registerPermissions(PermissionRegistry $registry): void; } diff --git a/src/Core/Module/Contract/QuickToolsProviderInterface.php b/src/Core/Module/Contract/QuickToolsProviderInterface.php index e1f05399..84f93cb3 100644 --- a/src/Core/Module/Contract/QuickToolsProviderInterface.php +++ b/src/Core/Module/Contract/QuickToolsProviderInterface.php @@ -11,13 +11,12 @@ use XcVm\Core\Module\QuickToolsRegistry; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface QuickToolsProviderInterface { - - /** - * Register one-shot Quick Tools actions via the provided registry. - * - * Called in bootAll() (same phase as the other providers), before the Quick - * Tools page renders and before post.php dispatches the action. Each tool - * contributes a button and a handler owned by the module. - */ - public function registerQuickTools(QuickToolsRegistry $registry): void; + /** + * Register one-shot Quick Tools actions via the provided registry. + * + * Called in bootAll() (same phase as the other providers), before the Quick + * Tools page renders and before post.php dispatches the action. Each tool + * contributes a button and a handler owned by the module. + */ + public function registerQuickTools(QuickToolsRegistry $registry): void; } diff --git a/src/Core/Module/Contract/RouteProviderInterface.php b/src/Core/Module/Contract/RouteProviderInterface.php index d636715c..edf7b7d9 100644 --- a/src/Core/Module/Contract/RouteProviderInterface.php +++ b/src/Core/Module/Contract/RouteProviderInterface.php @@ -11,13 +11,11 @@ use XcVm\Core\Http\Router; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface RouteProviderInterface { - - /** - * Register HTTP routes for this module. - * - * Called after boot(). Module registers GET/POST routes and API handlers. - * - * @param Router $router - */ - public function registerRoutes(Router $router): void; + /** + * Register HTTP routes for this module. + * + * Called after boot(). Module registers GET/POST routes and API handlers. + * + */ + public function registerRoutes(Router $router): void; } diff --git a/src/Core/Module/Contract/ServiceProviderInterface.php b/src/Core/Module/Contract/ServiceProviderInterface.php index 9df19d23..521a541b 100644 --- a/src/Core/Module/Contract/ServiceProviderInterface.php +++ b/src/Core/Module/Contract/ServiceProviderInterface.php @@ -11,24 +11,21 @@ use XcVm\Core\Container\ServiceContainer; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface ServiceProviderInterface { + /** + * Register module services in the DI container. + * + * Called once per request during module boot phase. + * + */ + public function boot(ServiceContainer $container): void; - /** - * Register module services in the DI container. - * - * Called once per request during module boot phase. - * - * @param ServiceContainer $container - */ - public function boot(ServiceContainer $container): void; - - /** - * Return event subscribers declared by this module. - * - * Key is a fully-qualified event class name, value is a callable - * or [callable, int $priority] tuple. - * - * @return array - */ - public function getEventSubscribers(): array; - + /** + * Return event subscribers declared by this module. + * + * Key is a fully-qualified event class name, value is a callable + * or [callable, int $priority] tuple. + * + * @return array + */ + public function getEventSubscribers(): array; } diff --git a/src/Core/Module/Contract/StreamMiddlewareProviderInterface.php b/src/Core/Module/Contract/StreamMiddlewareProviderInterface.php index 2b21993c..6892a946 100644 --- a/src/Core/Module/Contract/StreamMiddlewareProviderInterface.php +++ b/src/Core/Module/Contract/StreamMiddlewareProviderInterface.php @@ -23,14 +23,13 @@ use XcVm\Core\Http\Pipeline\StreamPipeline; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface StreamMiddlewareProviderInterface { - - /** - * Return stream middleware instances to inject into StreamPipeline. - * - * Modules return one or more middleware objects. Each must implement - * StreamMiddlewareInterface and declare its own priority via getPriority(). - * - * @return StreamMiddlewareInterface[] - */ - public function getStreamMiddleware(): array; + /** + * Return stream middleware instances to inject into StreamPipeline. + * + * Modules return one or more middleware objects. Each must implement + * StreamMiddlewareInterface and declare its own priority via getPriority(). + * + * @return StreamMiddlewareInterface[] + */ + public function getStreamMiddleware(): array; } diff --git a/src/Core/Module/Contract/TableProviderInterface.php b/src/Core/Module/Contract/TableProviderInterface.php index e5defeb6..d3555e42 100644 --- a/src/Core/Module/Contract/TableProviderInterface.php +++ b/src/Core/Module/Contract/TableProviderInterface.php @@ -11,13 +11,12 @@ use XcVm\Core\Module\TableRegistry; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface TableProviderInterface { - - /** - * Register serverSide DataTable handlers via the provided TableRegistry. - * - * Called in bootAll() (same phase as registerNavbar/registerTopbar), before - * the ./table request dispatches. Register a handler for each table id the - * module owns so its builder lives in the module, not in core TableController. - */ - public function registerTables(TableRegistry $registry): void; + /** + * Register serverSide DataTable handlers via the provided TableRegistry. + * + * Called in bootAll() (same phase as registerNavbar/registerTopbar), before + * the ./table request dispatches. Register a handler for each table id the + * module owns so its builder lives in the module, not in core TableController. + */ + public function registerTables(TableRegistry $registry): void; } diff --git a/src/Core/Module/Contract/TopbarProviderInterface.php b/src/Core/Module/Contract/TopbarProviderInterface.php index de6739cb..bfcb9465 100644 --- a/src/Core/Module/Contract/TopbarProviderInterface.php +++ b/src/Core/Module/Contract/TopbarProviderInterface.php @@ -11,14 +11,13 @@ use XcVm\Core\Module\TopbarRegistry; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface TopbarProviderInterface { - - /** - * Register per-page topbar buttons via the provided TopbarRegistry. - * - * Called in bootAll() after all modules have been booted (same phase as - * registerNavbar). A module may add buttons to its OWN pages and to - * existing core pages (e.g. inject a "Watch Folder" button into 'movies'). - * Use $registry->add() (static under the hood, mirrors NavbarRegistry). - */ - public function registerTopbar(TopbarRegistry $registry): void; + /** + * Register per-page topbar buttons via the provided TopbarRegistry. + * + * Called in bootAll() after all modules have been booted (same phase as + * registerNavbar). A module may add buttons to its OWN pages and to + * existing core pages (e.g. inject a "Watch Folder" button into 'movies'). + * Use $registry->add() (static under the hood, mirrors NavbarRegistry). + */ + public function registerTopbar(TopbarRegistry $registry): void; } diff --git a/src/Core/Module/CoreNavbarProvider.php b/src/Core/Module/CoreNavbarProvider.php index d986f70c..98874517 100644 --- a/src/Core/Module/CoreNavbarProvider.php +++ b/src/Core/Module/CoreNavbarProvider.php @@ -20,595 +20,594 @@ use XcVm\Core\Module\Contract\NavbarProviderInterface; */ class CoreNavbarProvider implements NavbarProviderInterface { + /** + * NavbarProviderInterface implementation — delegates to register(). + */ + public function registerNavbar(NavbarRegistry $registry): void { + self::register(); + } - /** - * NavbarProviderInterface implementation — delegates to register(). - */ - public function registerNavbar(NavbarRegistry $registry): void { - self::register(); - } + /** + * Register all core navigation items with the NavbarRegistry. + * + * @return void + */ + public static function register(): void { + self::_dashboard(); + self::_servers(); + self::_users(); + self::_content(); + self::_vod(); + self::_distribution(); + self::_logs(); + self::_management(); + self::_profile(); + } - /** - * Register all core navigation items with the NavbarRegistry. - * - * @return void - */ - public static function register(): void { - self::_dashboard(); - self::_servers(); - self::_users(); - self::_content(); - self::_vod(); - self::_distribution(); - self::_logs(); - self::_management(); - self::_profile(); - } + // ── Dashboard ───────────────────────────────────────────────── - // ── Dashboard ───────────────────────────────────────────────── + /** + * Register Dashboard navigation items. + * + * Adds the main dashboard menu item and its live connections sub-item. + * + * @return void + */ + private static function _dashboard(): void { + NavbarRegistry::add((new NavbarItem('dashboard')) + ->url('index')->label('dashboard') + ->icon('fe-activity')->noMobileSubmenu()->order(100)); - /** - * Register Dashboard navigation items. - * - * Adds the main dashboard menu item and its live connections sub-item. - * - * @return void - */ - private static function _dashboard(): void { - NavbarRegistry::add((new NavbarItem('dashboard')) - ->url('index')->label('dashboard') - ->icon('fe-activity')->noMobileSubmenu()->order(100)); + NavbarRegistry::add((new NavbarItem('dashboard.home')) + ->parent('dashboard')->url('dashboard') + ->label('home')->order(1)); + } - NavbarRegistry::add((new NavbarItem('dashboard.home')) - ->parent('dashboard')->url('dashboard') - ->label('home')->order(1)); - } + // ── Servers ─────────────────────────────────────────────────── - // ── Servers ─────────────────────────────────────────────────── + /** + * Register Servers navigation items. + * + * Adds server management items including load balancer installation, + * server/proxy management, ordering, and process monitoring. + * + * @return void + */ + private static function _servers(): void { + NavbarRegistry::add((new NavbarItem('servers')) + ->url('#')->label('servers') + ->icon('fas fa-server')->order(200)); - /** - * Register Servers navigation items. - * - * Adds server management items including load balancer installation, - * server/proxy management, ordering, and process monitoring. - * - * @return void - */ - private static function _servers(): void { - NavbarRegistry::add((new NavbarItem('servers')) - ->url('#')->label('servers') - ->icon('fas fa-server')->order(200)); + NavbarRegistry::add((new NavbarItem('servers.install')) + ->parent('servers')->url('server_install') + ->label('install_load_balancer')->permissions(['servers'])->order(10)); - NavbarRegistry::add((new NavbarItem('servers.install')) - ->parent('servers')->url('server_install') - ->label('install_load_balancer')->permissions(['servers'])->order(10)); + NavbarRegistry::add((new NavbarItem('servers.manage')) + ->parent('servers')->url('servers') + ->label('manage_servers')->permissions(['servers'])->order(20)); - NavbarRegistry::add((new NavbarItem('servers.manage')) - ->parent('servers')->url('servers') - ->label('manage_servers')->permissions(['servers'])->order(20)); + NavbarRegistry::add((new NavbarItem('servers.proxies')) + ->parent('servers')->url('proxies') + ->label('manage_proxies')->permissions(['proxies'])->order(30)); - NavbarRegistry::add((new NavbarItem('servers.proxies')) - ->parent('servers')->url('proxies') - ->label('manage_proxies')->permissions(['proxies'])->order(30)); + NavbarRegistry::add((new NavbarItem('servers.order')) + ->parent('servers')->url('server_order') + ->label('server_order')->permissions(['server_order'])->order(40)); - NavbarRegistry::add((new NavbarItem('servers.order')) - ->parent('servers')->url('server_order') - ->label('server_order')->permissions(['server_order'])->order(40)); + NavbarRegistry::add((new NavbarItem('servers.process_monitor')) + ->parent('servers')->url('process_monitor') + ->label('process_monitor')->permissions(['process_monitor'])->order(50)); + } - NavbarRegistry::add((new NavbarItem('servers.process_monitor')) - ->parent('servers')->url('process_monitor') - ->label('process_monitor')->permissions(['process_monitor'])->order(50)); - } + // ── Users ───────────────────────────────────────────────────── - // ── Users ───────────────────────────────────────────────────── + /** + * Register Users navigation items. + * + * Adds complete user management structure including Lines, MAG devices, + * Enigma2 devices, and Reseller management with their respective + * add/manage/mass-edit operations. + * + * @return void + */ + private static function _users(): void { + NavbarRegistry::add((new NavbarItem('users')) + ->url('#')->label('users') + ->icon('fas fa-desktop')->order(300)); - /** - * Register Users navigation items. - * - * Adds complete user management structure including Lines, MAG devices, - * Enigma2 devices, and Reseller management with their respective - * add/manage/mass-edit operations. - * - * @return void - */ - private static function _users(): void { - NavbarRegistry::add((new NavbarItem('users')) - ->url('#')->label('users') - ->icon('fas fa-desktop')->order(300)); + // Lines + NavbarRegistry::add((new NavbarItem('users.lines')) + ->parent('users')->url('#') + ->label('user_lines')->permissions(['add_user', 'users'])->order(10)); + NavbarRegistry::add((new NavbarItem('users.lines.add')) + ->parent('users.lines')->url('line') + ->label('add_line')->permissions(['add_user'])->order(10)); + NavbarRegistry::add((new NavbarItem('users.lines.manage')) + ->parent('users.lines')->url('lines') + ->label('manage_lines')->permissions(['users'])->order(20)); + NavbarRegistry::add((new NavbarItem('users.lines.mass')) + ->parent('users.lines')->url('line_mass') + ->label('mass_edit_lines')->permissions(['mass_edit_lines'])->order(30)); - // Lines - NavbarRegistry::add((new NavbarItem('users.lines')) - ->parent('users')->url('#') - ->label('user_lines')->permissions(['add_user', 'users'])->order(10)); - NavbarRegistry::add((new NavbarItem('users.lines.add')) - ->parent('users.lines')->url('line') - ->label('add_line')->permissions(['add_user'])->order(10)); - NavbarRegistry::add((new NavbarItem('users.lines.manage')) - ->parent('users.lines')->url('lines') - ->label('manage_lines')->permissions(['users'])->order(20)); - NavbarRegistry::add((new NavbarItem('users.lines.mass')) - ->parent('users.lines')->url('line_mass') - ->label('mass_edit_lines')->permissions(['mass_edit_lines'])->order(30)); + // Active Codes + NavbarRegistry::add((new NavbarItem('users.active_codes')) + ->parent('users')->url('#') + ->label('active_codes')->permissions(['add_user', 'users'])->order(15)); + NavbarRegistry::add((new NavbarItem('users.active_codes.add')) + ->parent('users.active_codes')->url('active_code') + ->label('generate_codes')->permissions(['add_user'])->order(10)); + NavbarRegistry::add((new NavbarItem('users.active_codes.manage')) + ->parent('users.active_codes')->url('active_codes') + ->label('manage_active_codes')->permissions(['users'])->order(20)); + NavbarRegistry::add((new NavbarItem('users.active_codes.batch')) + ->parent('users.active_codes')->url('active_codes_batch') + ->label('batch_manager')->permissions(['users'])->order(25)); + NavbarRegistry::add((new NavbarItem('users.active_codes.mass')) + ->parent('users.active_codes')->url('active_codes_mass') + ->label('mass_edit_active_codes')->permissions(['mass_edit_lines'])->order(30)); - // Active Codes - NavbarRegistry::add((new NavbarItem('users.active_codes')) - ->parent('users')->url('#') - ->label('active_codes')->permissions(['add_user', 'users'])->order(15)); - NavbarRegistry::add((new NavbarItem('users.active_codes.add')) - ->parent('users.active_codes')->url('active_code') - ->label('generate_codes')->permissions(['add_user'])->order(10)); - NavbarRegistry::add((new NavbarItem('users.active_codes.manage')) - ->parent('users.active_codes')->url('active_codes') - ->label('manage_active_codes')->permissions(['users'])->order(20)); - NavbarRegistry::add((new NavbarItem('users.active_codes.batch')) - ->parent('users.active_codes')->url('active_codes_batch') - ->label('batch_manager')->permissions(['users'])->order(25)); - NavbarRegistry::add((new NavbarItem('users.active_codes.mass')) - ->parent('users.active_codes')->url('active_codes_mass') - ->label('mass_edit_active_codes')->permissions(['mass_edit_lines'])->order(30)); + // MAG + NavbarRegistry::add((new NavbarItem('users.mag')) + ->parent('users')->url('#') + ->label('mag_devices')->permissions(['add_mag', 'manage_mag'])->order(20)); + NavbarRegistry::add((new NavbarItem('users.mag.add')) + ->parent('users.mag')->url('mag') + ->label('add_mag')->permissions(['add_mag'])->order(10)); + NavbarRegistry::add((new NavbarItem('users.mag.manage')) + ->parent('users.mag')->url('mags') + ->label('manage_mag_devices')->permissions(['manage_mag'])->order(20)); + NavbarRegistry::add((new NavbarItem('users.mag.mass')) + ->parent('users.mag')->url('mag_mass') + ->label('mass_edit_mags')->permissions(['mass_edit_mags'])->order(30)); - // MAG - NavbarRegistry::add((new NavbarItem('users.mag')) - ->parent('users')->url('#') - ->label('mag_devices')->permissions(['add_mag', 'manage_mag'])->order(20)); - NavbarRegistry::add((new NavbarItem('users.mag.add')) - ->parent('users.mag')->url('mag') - ->label('add_mag')->permissions(['add_mag'])->order(10)); - NavbarRegistry::add((new NavbarItem('users.mag.manage')) - ->parent('users.mag')->url('mags') - ->label('manage_mag_devices')->permissions(['manage_mag'])->order(20)); - NavbarRegistry::add((new NavbarItem('users.mag.mass')) - ->parent('users.mag')->url('mag_mass') - ->label('mass_edit_mags')->permissions(['mass_edit_mags'])->order(30)); + // Enigma + NavbarRegistry::add((new NavbarItem('users.e2')) + ->parent('users')->url('#') + ->label('enigma_devices')->permissions(['add_e2', 'manage_e2'])->order(30)); + NavbarRegistry::add((new NavbarItem('users.e2.add')) + ->parent('users.e2')->url('enigma') + ->label('add_enigma')->permissions(['add_e2'])->order(10)); + NavbarRegistry::add((new NavbarItem('users.e2.manage')) + ->parent('users.e2')->url('enigmas') + ->label('manage_enigma_devices')->permissions(['manage_e2'])->order(20)); + NavbarRegistry::add((new NavbarItem('users.e2.mass')) + ->parent('users.e2')->url('enigma_mass') + ->label('mass_edit_enigmas')->permissions(['mass_edit_enigmas'])->order(30)); - // Enigma - NavbarRegistry::add((new NavbarItem('users.e2')) - ->parent('users')->url('#') - ->label('enigma_devices')->permissions(['add_e2', 'manage_e2'])->order(30)); - NavbarRegistry::add((new NavbarItem('users.e2.add')) - ->parent('users.e2')->url('enigma') - ->label('add_enigma')->permissions(['add_e2'])->order(10)); - NavbarRegistry::add((new NavbarItem('users.e2.manage')) - ->parent('users.e2')->url('enigmas') - ->label('manage_enigma_devices')->permissions(['manage_e2'])->order(20)); - NavbarRegistry::add((new NavbarItem('users.e2.mass')) - ->parent('users.e2')->url('enigma_mass') - ->label('mass_edit_enigmas')->permissions(['mass_edit_enigmas'])->order(30)); + // Reseller + NavbarRegistry::add((new NavbarItem('users.reseller')) + ->parent('users')->url('#') + ->label('reseller')->permissions(['add_reguser', 'mng_regusers'])->order(40)); + NavbarRegistry::add((new NavbarItem('users.reseller.add')) + ->parent('users.reseller')->url('user') + ->label('add_registered_user')->permissions(['add_reguser'])->order(10)); + NavbarRegistry::add((new NavbarItem('users.reseller.manage')) + ->parent('users.reseller')->url('users') + ->label('manage_registered_user')->permissions(['mng_regusers'])->order(20)); + NavbarRegistry::add((new NavbarItem('users.reseller.mass')) + ->parent('users.reseller')->url('user_mass') + ->label('mass_edit_resellers')->permissions(['mass_edit_users'])->order(30)); + } - // Reseller - NavbarRegistry::add((new NavbarItem('users.reseller')) - ->parent('users')->url('#') - ->label('reseller')->permissions(['add_reguser', 'mng_regusers'])->order(40)); - NavbarRegistry::add((new NavbarItem('users.reseller.add')) - ->parent('users.reseller')->url('user') - ->label('add_registered_user')->permissions(['add_reguser'])->order(10)); - NavbarRegistry::add((new NavbarItem('users.reseller.manage')) - ->parent('users.reseller')->url('users') - ->label('manage_registered_user')->permissions(['mng_regusers'])->order(20)); - NavbarRegistry::add((new NavbarItem('users.reseller.mass')) - ->parent('users.reseller')->url('user_mass') - ->label('mass_edit_resellers')->permissions(['mass_edit_users'])->order(30)); - } + // ── Content ─────────────────────────────────────────────────── - // ── Content ─────────────────────────────────────────────────── + /** + * Register Content (Streaming) navigation items. + * + * Live-streaming content: Streams, Created Channels and Radio Stations. + * VOD (Movies/Series) moved to _vod(); Bouquets/Suppliers/Recordings/TV Guide + * moved to _distribution(). Labelled "Streaming"; the key stays 'content'. + * + * @return void + */ + private static function _content(): void { + NavbarRegistry::add((new NavbarItem('content')) + ->url('#')->label('', 'Streaming') + ->icon('fas fa-play')->order(400)); - /** - * Register Content (Streaming) navigation items. - * - * Live-streaming content: Streams, Created Channels and Radio Stations. - * VOD (Movies/Series) moved to _vod(); Bouquets/Suppliers/Recordings/TV Guide - * moved to _distribution(). Labelled "Streaming"; the key stays 'content'. - * - * @return void - */ - private static function _content(): void { - NavbarRegistry::add((new NavbarItem('content')) - ->url('#')->label('', 'Streaming') - ->icon('fas fa-play')->order(400)); + // Streams + NavbarRegistry::add((new NavbarItem('content.streams')) + ->parent('content')->url('#') + ->label('streams')->permissions(['add_stream', 'streams'])->order(10)); + NavbarRegistry::add((new NavbarItem('content.streams.add')) + ->parent('content.streams')->url('stream') + ->label('add_stream')->permissions(['add_stream'])->order(10)); + NavbarRegistry::add((new NavbarItem('content.streams.import')) + ->parent('content.streams')->url('stream?import=1') + ->label('import_multiple_stream')->permissions(['import_streams'])->order(20)); + NavbarRegistry::add((new NavbarItem('content.streams.import_review')) + ->parent('content.streams')->url('review?type=1') + ->label('import_review_stream')->permissions(['import_streams'])->order(30)); + NavbarRegistry::add((new NavbarItem('content.streams.manage')) + ->parent('content.streams')->url('streams') + ->label('manage_streams')->permissions(['streams'])->order(40)); + NavbarRegistry::add((new NavbarItem('content.streams.mass')) + ->parent('content.streams')->url('stream_mass') + ->label('mass_edit_streams')->permissions(['mass_edit_streams'])->order(50)); - // Streams - NavbarRegistry::add((new NavbarItem('content.streams')) - ->parent('content')->url('#') - ->label('streams')->permissions(['add_stream', 'streams'])->order(10)); - NavbarRegistry::add((new NavbarItem('content.streams.add')) - ->parent('content.streams')->url('stream') - ->label('add_stream')->permissions(['add_stream'])->order(10)); - NavbarRegistry::add((new NavbarItem('content.streams.import')) - ->parent('content.streams')->url('stream?import=1') - ->label('import_multiple_stream')->permissions(['import_streams'])->order(20)); - NavbarRegistry::add((new NavbarItem('content.streams.import_review')) - ->parent('content.streams')->url('review?type=1') - ->label('import_review_stream')->permissions(['import_streams'])->order(30)); - NavbarRegistry::add((new NavbarItem('content.streams.manage')) - ->parent('content.streams')->url('streams') - ->label('manage_streams')->permissions(['streams'])->order(40)); - NavbarRegistry::add((new NavbarItem('content.streams.mass')) - ->parent('content.streams')->url('stream_mass') - ->label('mass_edit_streams')->permissions(['mass_edit_streams'])->order(50)); + // Created channels + NavbarRegistry::add((new NavbarItem('content.channels')) + ->parent('content')->url('#') + ->label('created_channels')->permissions(['create_channel', 'streams'])->order(20)); + NavbarRegistry::add((new NavbarItem('content.channels.add')) + ->parent('content.channels')->url('created_channel') + ->label('create_channel')->permissions(['create_channel'])->order(10)); + NavbarRegistry::add((new NavbarItem('content.channels.manage')) + ->parent('content.channels')->url('created_channels') + ->label('manage_created_channels')->permissions(['streams'])->order(20)); + NavbarRegistry::add((new NavbarItem('content.channels.mass')) + ->parent('content.channels')->url('created_channel_mass') + ->label('mass_edit_created_channels')->permissions(['streams'])->order(30)); - // Created channels - NavbarRegistry::add((new NavbarItem('content.channels')) - ->parent('content')->url('#') - ->label('created_channels')->permissions(['create_channel', 'streams'])->order(20)); - NavbarRegistry::add((new NavbarItem('content.channels.add')) - ->parent('content.channels')->url('created_channel') - ->label('create_channel')->permissions(['create_channel'])->order(10)); - NavbarRegistry::add((new NavbarItem('content.channels.manage')) - ->parent('content.channels')->url('created_channels') - ->label('manage_created_channels')->permissions(['streams'])->order(20)); - NavbarRegistry::add((new NavbarItem('content.channels.mass')) - ->parent('content.channels')->url('created_channel_mass') - ->label('mass_edit_created_channels')->permissions(['streams'])->order(30)); + // Radio stations + NavbarRegistry::add((new NavbarItem('content.stations')) + ->parent('content')->url('#') + ->label('stations')->permissions(['add_radio', 'radio'])->order(30)); + NavbarRegistry::add((new NavbarItem('content.stations.add')) + ->parent('content.stations')->url('radio') + ->label('add_station')->permissions(['add_radio'])->order(10)); + NavbarRegistry::add((new NavbarItem('content.stations.manage')) + ->parent('content.stations')->url('radios') + ->label('manage_stations')->permissions(['radio'])->order(20)); + NavbarRegistry::add((new NavbarItem('content.stations.mass')) + ->parent('content.stations')->url('radio_mass') + ->label('mass_edit_stations')->permissions(['mass_edit_radio'])->order(30)); + } - // Radio stations - NavbarRegistry::add((new NavbarItem('content.stations')) - ->parent('content')->url('#') - ->label('stations')->permissions(['add_radio', 'radio'])->order(30)); - NavbarRegistry::add((new NavbarItem('content.stations.add')) - ->parent('content.stations')->url('radio') - ->label('add_station')->permissions(['add_radio'])->order(10)); - NavbarRegistry::add((new NavbarItem('content.stations.manage')) - ->parent('content.stations')->url('radios') - ->label('manage_stations')->permissions(['radio'])->order(20)); - NavbarRegistry::add((new NavbarItem('content.stations.mass')) - ->parent('content.stations')->url('radio_mass') - ->label('mass_edit_stations')->permissions(['mass_edit_radio'])->order(30)); - } + // ── VOD (Movies / Series) ───────────────────────────────────── - // ── VOD (Movies / Series) ───────────────────────────────────── + /** + * Register VOD navigation items (top-level tab, order 410). + * + * On-demand catalogue split out of Content: Movies and Series with their + * add/import/manage/mass operations. Each leaf keeps its original + * url/permissions/label; only the parent key changed (content.* → vod.*). + * + * @return void + */ + private static function _vod(): void { + NavbarRegistry::add((new NavbarItem('vod')) + ->url('#')->label('', 'VOD') + ->icon('fas fa-film')->order(410)); - /** - * Register VOD navigation items (top-level tab, order 410). - * - * On-demand catalogue split out of Content: Movies and Series with their - * add/import/manage/mass operations. Each leaf keeps its original - * url/permissions/label; only the parent key changed (content.* → vod.*). - * - * @return void - */ - private static function _vod(): void { - NavbarRegistry::add((new NavbarItem('vod')) - ->url('#')->label('', 'VOD') - ->icon('fas fa-film')->order(410)); + // Movies + NavbarRegistry::add((new NavbarItem('vod.movies')) + ->parent('vod')->url('#') + ->label('movies')->permissions(['add_movie', 'import_movies', 'movies'])->order(10)); + NavbarRegistry::add((new NavbarItem('vod.movies.add')) + ->parent('vod.movies')->url('movie') + ->label('add_movie')->permissions(['add_movie'])->order(10)); + NavbarRegistry::add((new NavbarItem('vod.movies.import')) + ->parent('vod.movies')->url('movie?import=1') + ->label('import_multiple_movies')->permissions(['import_movies'])->order(20)); + NavbarRegistry::add((new NavbarItem('vod.movies.import_review')) + ->parent('vod.movies')->url('review?type=2') + ->label('import_review_movies')->permissions(['import_movies'])->order(30)); + NavbarRegistry::add((new NavbarItem('vod.movies.manage')) + ->parent('vod.movies')->url('movies') + ->label('manage_movies')->permissions(['movies'])->order(40)); + NavbarRegistry::add((new NavbarItem('vod.movies.mass')) + ->parent('vod.movies')->url('movie_mass') + ->label('mass_edit_movies')->permissions(['mass_sedits_vod'])->order(50)); - // Movies - NavbarRegistry::add((new NavbarItem('vod.movies')) - ->parent('vod')->url('#') - ->label('movies')->permissions(['add_movie', 'import_movies', 'movies'])->order(10)); - NavbarRegistry::add((new NavbarItem('vod.movies.add')) - ->parent('vod.movies')->url('movie') - ->label('add_movie')->permissions(['add_movie'])->order(10)); - NavbarRegistry::add((new NavbarItem('vod.movies.import')) - ->parent('vod.movies')->url('movie?import=1') - ->label('import_multiple_movies')->permissions(['import_movies'])->order(20)); - NavbarRegistry::add((new NavbarItem('vod.movies.import_review')) - ->parent('vod.movies')->url('review?type=2') - ->label('import_review_movies')->permissions(['import_movies'])->order(30)); - NavbarRegistry::add((new NavbarItem('vod.movies.manage')) - ->parent('vod.movies')->url('movies') - ->label('manage_movies')->permissions(['movies'])->order(40)); - NavbarRegistry::add((new NavbarItem('vod.movies.mass')) - ->parent('vod.movies')->url('movie_mass') - ->label('mass_edit_movies')->permissions(['mass_sedits_vod'])->order(50)); + // Series + NavbarRegistry::add((new NavbarItem('vod.series')) + ->parent('vod')->url('#') + ->label('series')->permissions(['add_series', 'series', 'episodes'])->order(20)); + NavbarRegistry::add((new NavbarItem('vod.series.add')) + ->parent('vod.series')->url('serie') + ->label('add_series')->permissions(['add_series'])->order(10)); + NavbarRegistry::add((new NavbarItem('vod.series.manage')) + ->parent('vod.series')->url('series') + ->label('manage_series')->permissions(['series'])->order(20)); + NavbarRegistry::add((new NavbarItem('vod.series.episodes')) + ->parent('vod.series')->url('episodes') + ->label('manage_episodes')->permissions(['episodes'])->order(30)); + NavbarRegistry::add((new NavbarItem('vod.series.mass')) + ->parent('vod.series')->url('series_mass') + ->label('', 'Mass Edit Series')->permissions(['mass_sedits'])->order(40)); + NavbarRegistry::add((new NavbarItem('vod.series.episodes_mass')) + ->parent('vod.series')->url('episodes_mass') + ->label('', 'Mass Edit Episodes')->permissions(['mass_sedits'])->order(50)); + } - // Series - NavbarRegistry::add((new NavbarItem('vod.series')) - ->parent('vod')->url('#') - ->label('series')->permissions(['add_series', 'series', 'episodes'])->order(20)); - NavbarRegistry::add((new NavbarItem('vod.series.add')) - ->parent('vod.series')->url('serie') - ->label('add_series')->permissions(['add_series'])->order(10)); - NavbarRegistry::add((new NavbarItem('vod.series.manage')) - ->parent('vod.series')->url('series') - ->label('manage_series')->permissions(['series'])->order(20)); - NavbarRegistry::add((new NavbarItem('vod.series.episodes')) - ->parent('vod.series')->url('episodes') - ->label('manage_episodes')->permissions(['episodes'])->order(30)); - NavbarRegistry::add((new NavbarItem('vod.series.mass')) - ->parent('vod.series')->url('series_mass') - ->label('', 'Mass Edit Series')->permissions(['mass_sedits'])->order(40)); - NavbarRegistry::add((new NavbarItem('vod.series.episodes_mass')) - ->parent('vod.series')->url('episodes_mass') - ->label('', 'Mass Edit Episodes')->permissions(['mass_sedits'])->order(50)); - } + // ── Distribution (Bouquets / Suppliers / Recordings / Guide) ── - // ── Distribution (Bouquets / Suppliers / Recordings / Guide) ── + /** + * Register Distribution navigation items (top-level tab, order 420). + * + * Content organisation & delivery split out of Content: Bouquets, Suppliers, + * Recordings and TV Guide. Each leaf keeps its original url/permissions/label; + * only the parent key changed (content.* → distribution.*). + * + * @return void + */ + private static function _distribution(): void { + NavbarRegistry::add((new NavbarItem('distribution')) + ->url('#')->label('', 'Distribution') + ->icon('fas fa-sitemap')->order(420)); - /** - * Register Distribution navigation items (top-level tab, order 420). - * - * Content organisation & delivery split out of Content: Bouquets, Suppliers, - * Recordings and TV Guide. Each leaf keeps its original url/permissions/label; - * only the parent key changed (content.* → distribution.*). - * - * @return void - */ - private static function _distribution(): void { - NavbarRegistry::add((new NavbarItem('distribution')) - ->url('#')->label('', 'Distribution') - ->icon('fas fa-sitemap')->order(420)); + // Bouquets + NavbarRegistry::add((new NavbarItem('distribution.bouquets')) + ->parent('distribution')->url('#') + ->label('bouquets')->permissions(['add_bouquet', 'bouquets', 'bouquet_order'])->order(10)); + NavbarRegistry::add((new NavbarItem('distribution.bouquets.add')) + ->parent('distribution.bouquets')->url('bouquet') + ->label('add_bouquet')->permissions(['add_bouquet'])->order(10)); + NavbarRegistry::add((new NavbarItem('distribution.bouquets.manage')) + ->parent('distribution.bouquets')->url('bouquets') + ->label('manage_bouquets')->permissions(['bouquets'])->order(20)); + NavbarRegistry::add((new NavbarItem('distribution.bouquets.order')) + ->parent('distribution.bouquets')->url('bouquet_order') + ->label('bouquet_order')->permissions(['bouquet_order']) + ->desktopOnly()->order(30)); - // Bouquets - NavbarRegistry::add((new NavbarItem('distribution.bouquets')) - ->parent('distribution')->url('#') - ->label('bouquets')->permissions(['add_bouquet', 'bouquets', 'bouquet_order'])->order(10)); - NavbarRegistry::add((new NavbarItem('distribution.bouquets.add')) - ->parent('distribution.bouquets')->url('bouquet') - ->label('add_bouquet')->permissions(['add_bouquet'])->order(10)); - NavbarRegistry::add((new NavbarItem('distribution.bouquets.manage')) - ->parent('distribution.bouquets')->url('bouquets') - ->label('manage_bouquets')->permissions(['bouquets'])->order(20)); - NavbarRegistry::add((new NavbarItem('distribution.bouquets.order')) - ->parent('distribution.bouquets')->url('bouquet_order') - ->label('bouquet_order')->permissions(['bouquet_order']) - ->desktopOnly()->order(30)); + // Suppliers + NavbarRegistry::add((new NavbarItem('distribution.suppliers')) + ->parent('distribution')->url('#') + ->label('suppliers')->permissions(['streams'])->order(20)); + NavbarRegistry::add((new NavbarItem('distribution.suppliers.add')) + ->parent('distribution.suppliers')->url('provider') + ->label('add_providers')->permissions(['streams'])->order(10)); + NavbarRegistry::add((new NavbarItem('distribution.suppliers.manage')) + ->parent('distribution.suppliers')->url('providers') + ->label('stream_providers')->permissions(['streams'])->order(20)); - // Suppliers - NavbarRegistry::add((new NavbarItem('distribution.suppliers')) - ->parent('distribution')->url('#') - ->label('suppliers')->permissions(['streams'])->order(20)); - NavbarRegistry::add((new NavbarItem('distribution.suppliers.add')) - ->parent('distribution.suppliers')->url('provider') - ->label('add_providers')->permissions(['streams'])->order(10)); - NavbarRegistry::add((new NavbarItem('distribution.suppliers.manage')) - ->parent('distribution.suppliers')->url('providers') - ->label('stream_providers')->permissions(['streams'])->order(20)); + // Recordings + NavbarRegistry::add((new NavbarItem('distribution.recordings')) + ->parent('distribution')->url('archive') + ->label('recordings')->permissions(['movies'])->order(30)); - // Recordings - NavbarRegistry::add((new NavbarItem('distribution.recordings')) - ->parent('distribution')->url('archive') - ->label('recordings')->permissions(['movies'])->order(30)); + // TV Guide + NavbarRegistry::add((new NavbarItem('distribution.tv_guide')) + ->parent('distribution')->url('epg_view') + ->label('tv_guide')->permissions(['streams']) + ->desktopOnly()->order(40)); + } - // TV Guide - NavbarRegistry::add((new NavbarItem('distribution.tv_guide')) - ->parent('distribution')->url('epg_view') - ->label('tv_guide')->permissions(['streams']) - ->desktopOnly()->order(40)); - } + // ── Logs ────────────────────────────────────────────────────── - // ── Logs ────────────────────────────────────────────────────── + /** + * Register Logs navigation items. + * + * Promoted to its own top-level tab (formerly the 'management.logs' + * megamenu). The ~16 log screens are grouped into four submenus — + * Connections, Streams, System, Users — for scannability. Each leaf keeps + * its original url/permissions/label; only the parent key changed. + * + * Modules inject extra log screens under 'logs' (or one of its subgroups) + * at order 500+. + * + * @return void + */ + private static function _logs(): void { + NavbarRegistry::add((new NavbarItem('logs')) + ->url('#')->label('logs') + ->icon('fas fa-clipboard-list') + ->permissions(['movies', 'streams', 'connection_logs', 'client_request_log', 'login_logs', 'panel_logs', 'credits_log', 'live_connections', 'manage_events', 'reg_userlog', 'stream_errors', 'restream_logs', 'episodes', 'series']) + ->order(500)); - /** - * Register Logs navigation items. - * - * Promoted to its own top-level tab (formerly the 'management.logs' - * megamenu). The ~16 log screens are grouped into four submenus — - * Connections, Streams, System, Users — for scannability. Each leaf keeps - * its original url/permissions/label; only the parent key changed. - * - * Modules inject extra log screens under 'logs' (or one of its subgroups) - * at order 500+. - * - * @return void - */ - private static function _logs(): void { - NavbarRegistry::add((new NavbarItem('logs')) - ->url('#')->label('logs') - ->icon('fas fa-clipboard-list') - ->permissions(['movies', 'streams', 'connection_logs', 'client_request_log', 'login_logs', 'panel_logs', 'credits_log', 'live_connections', 'manage_events', 'reg_userlog', 'stream_errors', 'restream_logs', 'episodes', 'series']) - ->order(500)); + // Connections + NavbarRegistry::add((new NavbarItem('logs.connections')) + ->parent('logs')->url('#') + ->label('logs_group_connections')->permissions(['connection_logs', 'live_connections', 'client_request_log'])->order(10)); + NavbarRegistry::add((new NavbarItem('logs.connections.activity')) + ->parent('logs.connections')->url('line_activity') + ->label('activity_logs')->permissions(['connection_logs'])->order(10)); + NavbarRegistry::add((new NavbarItem('logs.connections.live')) + ->parent('logs.connections')->url('live_connections') + ->label('live_connections')->permissions(['live_connections'])->order(20)); + NavbarRegistry::add((new NavbarItem('logs.connections.line_ips')) + ->parent('logs.connections')->url('line_ips') + ->label('ips_per_line')->permissions(['connection_logs'])->order(30)); + NavbarRegistry::add((new NavbarItem('logs.connections.client')) + ->parent('logs.connections')->url('client_logs') + ->label('client_logs')->permissions(['client_request_log'])->order(40)); - // Connections - NavbarRegistry::add((new NavbarItem('logs.connections')) - ->parent('logs')->url('#') - ->label('logs_group_connections')->permissions(['connection_logs', 'live_connections', 'client_request_log'])->order(10)); - NavbarRegistry::add((new NavbarItem('logs.connections.activity')) - ->parent('logs.connections')->url('line_activity') - ->label('activity_logs')->permissions(['connection_logs'])->order(10)); - NavbarRegistry::add((new NavbarItem('logs.connections.live')) - ->parent('logs.connections')->url('live_connections') - ->label('live_connections')->permissions(['live_connections'])->order(20)); - NavbarRegistry::add((new NavbarItem('logs.connections.line_ips')) - ->parent('logs.connections')->url('line_ips') - ->label('ips_per_line')->permissions(['connection_logs'])->order(30)); - NavbarRegistry::add((new NavbarItem('logs.connections.client')) - ->parent('logs.connections')->url('client_logs') - ->label('client_logs')->permissions(['client_request_log'])->order(40)); + // Streams + NavbarRegistry::add((new NavbarItem('logs.streams')) + ->parent('logs')->url('#') + ->label('logs_group_streams')->permissions(['stream_errors', 'streams', 'restream_logs'])->order(20)); + NavbarRegistry::add((new NavbarItem('logs.streams.errors')) + ->parent('logs.streams')->url('stream_errors') + ->label('stream_errors')->permissions(['stream_errors'])->order(10)); + NavbarRegistry::add((new NavbarItem('logs.streams.rank')) + ->parent('logs.streams')->url('stream_rank') + ->label('', 'Stream Rank')->permissions(['streams'])->order(20)); + NavbarRegistry::add((new NavbarItem('logs.streams.ondemand')) + ->parent('logs.streams')->url('ondemand') + ->label('', 'On-Demand Scanner')->permissions(['streams'])->order(30)); + NavbarRegistry::add((new NavbarItem('logs.streams.restream')) + ->parent('logs.streams')->url('restream_logs') + ->label('', 'Restream Detection')->permissions(['restream_logs'])->order(40)); - // Streams - NavbarRegistry::add((new NavbarItem('logs.streams')) - ->parent('logs')->url('#') - ->label('logs_group_streams')->permissions(['stream_errors', 'streams', 'restream_logs'])->order(20)); - NavbarRegistry::add((new NavbarItem('logs.streams.errors')) - ->parent('logs.streams')->url('stream_errors') - ->label('stream_errors')->permissions(['stream_errors'])->order(10)); - NavbarRegistry::add((new NavbarItem('logs.streams.rank')) - ->parent('logs.streams')->url('stream_rank') - ->label('', 'Stream Rank')->permissions(['streams'])->order(20)); - NavbarRegistry::add((new NavbarItem('logs.streams.ondemand')) - ->parent('logs.streams')->url('ondemand') - ->label('', 'On-Demand Scanner')->permissions(['streams'])->order(30)); - NavbarRegistry::add((new NavbarItem('logs.streams.restream')) - ->parent('logs.streams')->url('restream_logs') - ->label('', 'Restream Detection')->permissions(['restream_logs'])->order(40)); + // System + NavbarRegistry::add((new NavbarItem('logs.system')) + ->parent('logs')->url('#') + ->label('logs_group_system')->permissions(['panel_logs', 'login_logs', 'streams', 'episodes', 'series'])->order(30)); + NavbarRegistry::add((new NavbarItem('logs.system.panel')) + ->parent('logs.system')->url('panel_logs') + ->label('', 'Panel Errors')->permissions(['panel_logs'])->order(10)); + NavbarRegistry::add((new NavbarItem('logs.system.syslog')) + ->parent('logs.system')->url('mysql_syslog') + ->label('', 'System Logs')->permissions(['panel_logs'])->order(20)); + NavbarRegistry::add((new NavbarItem('logs.system.login')) + ->parent('logs.system')->url('login_logs') + ->label('', 'Login Logs')->permissions(['login_logs'])->order(30)); + NavbarRegistry::add((new NavbarItem('logs.system.queue')) + ->parent('logs.system')->url('queue') + ->label('', 'Encoding Queue')->permissions(['streams', 'episodes', 'series'])->order(40)); - // System - NavbarRegistry::add((new NavbarItem('logs.system')) - ->parent('logs')->url('#') - ->label('logs_group_system')->permissions(['panel_logs', 'login_logs', 'streams', 'episodes', 'series'])->order(30)); - NavbarRegistry::add((new NavbarItem('logs.system.panel')) - ->parent('logs.system')->url('panel_logs') - ->label('', 'Panel Errors')->permissions(['panel_logs'])->order(10)); - NavbarRegistry::add((new NavbarItem('logs.system.syslog')) - ->parent('logs.system')->url('mysql_syslog') - ->label('', 'System Logs')->permissions(['panel_logs'])->order(20)); - NavbarRegistry::add((new NavbarItem('logs.system.login')) - ->parent('logs.system')->url('login_logs') - ->label('', 'Login Logs')->permissions(['login_logs'])->order(30)); - NavbarRegistry::add((new NavbarItem('logs.system.queue')) - ->parent('logs.system')->url('queue') - ->label('', 'Encoding Queue')->permissions(['streams', 'episodes', 'series'])->order(40)); + // Users + NavbarRegistry::add((new NavbarItem('logs.users')) + ->parent('logs')->url('#') + ->label('logs_group_users')->permissions(['reg_userlog', 'credits_log', 'manage_events', 'movies'])->order(40)); + NavbarRegistry::add((new NavbarItem('logs.users.reseller')) + ->parent('logs.users')->url('user_logs') + ->label('reseller_logs')->permissions(['reg_userlog'])->order(10)); + NavbarRegistry::add((new NavbarItem('logs.users.credit')) + ->parent('logs.users')->url('credit_logs') + ->label('credit_logs')->permissions(['credits_log'])->order(20)); + NavbarRegistry::add((new NavbarItem('logs.users.mag_events')) + ->parent('logs.users')->url('mag_events') + ->label('mag_event_logs')->permissions(['manage_events'])->order(30)); + NavbarRegistry::add((new NavbarItem('logs.users.vod_theft')) + ->parent('logs.users')->url('theft_detection') + ->label('', 'VOD Theft Detection')->permissions(['movies'])->order(40)); + } - // Users - NavbarRegistry::add((new NavbarItem('logs.users')) - ->parent('logs')->url('#') - ->label('logs_group_users')->permissions(['reg_userlog', 'credits_log', 'manage_events', 'movies'])->order(40)); - NavbarRegistry::add((new NavbarItem('logs.users.reseller')) - ->parent('logs.users')->url('user_logs') - ->label('reseller_logs')->permissions(['reg_userlog'])->order(10)); - NavbarRegistry::add((new NavbarItem('logs.users.credit')) - ->parent('logs.users')->url('credit_logs') - ->label('credit_logs')->permissions(['credits_log'])->order(20)); - NavbarRegistry::add((new NavbarItem('logs.users.mag_events')) - ->parent('logs.users')->url('mag_events') - ->label('mag_event_logs')->permissions(['manage_events'])->order(30)); - NavbarRegistry::add((new NavbarItem('logs.users.vod_theft')) - ->parent('logs.users')->url('theft_detection') - ->label('', 'VOD Theft Detection')->permissions(['movies'])->order(40)); - } + // ── Management ──────────────────────────────────────────────── - // ── Management ──────────────────────────────────────────────── + /** + * Register Management navigation items (labelled "System"). + * + * Adds system management structure including Service Setup, Access Codes, + * Security, Tools, and Tickets. Logs live in their own top-level tab now + * (see _logs()). + * + * @return void + */ + private static function _management(): void { + // The former "System" group is flattened: its sections (Service Setup, + // Access Codes, Security, Tools, Tickets) are now self-standing top-level + // items. The registry KEYS stay 'management.*' so the reserved child slots + // (management.service_setup 60+, etc.) and every child ->parent() keep working. - /** - * Register Management navigation items (labelled "System"). - * - * Adds system management structure including Service Setup, Access Codes, - * Security, Tools, and Tickets. Logs live in their own top-level tab now - * (see _logs()). - * - * @return void - */ - private static function _management(): void { - // The former "System" group is flattened: its sections (Service Setup, - // Access Codes, Security, Tools, Tickets) are now self-standing top-level - // items. The registry KEYS stay 'management.*' so the reserved child slots - // (management.service_setup 60+, etc.) and every child ->parent() keep working. + // Service setup + NavbarRegistry::add((new NavbarItem('management.service_setup')) + ->url('#')->icon('fas fa-cog') + ->label('service_setup')->permissions(['mng_packages', 'categories', 'mng_groups', 'epg', 'tprofiles', 'folder_watch'])->order(600)); + NavbarRegistry::add((new NavbarItem('management.service_setup.packages')) + ->parent('management.service_setup')->url('packages') + ->label('packages')->permissions(['mng_packages'])->order(10)); + NavbarRegistry::add((new NavbarItem('management.service_setup.categories')) + ->parent('management.service_setup')->url('stream_categories') + ->label('categories')->permissions(['categories'])->order(20)); + NavbarRegistry::add((new NavbarItem('management.service_setup.groups')) + ->parent('management.service_setup')->url('groups') + ->label('groups')->permissions(['mng_groups'])->order(30)); + NavbarRegistry::add((new NavbarItem('management.service_setup.epg')) + ->parent('management.service_setup')->url('epgs') + ->label('epgs')->permissions(['epg'])->order(40)); + NavbarRegistry::add((new NavbarItem('management.service_setup.profiles')) + ->parent('management.service_setup')->url('profiles') + ->label('transcode_profiles')->permissions(['tprofiles'])->order(50)); + // Modules inject at order 60+ - // Service setup - NavbarRegistry::add((new NavbarItem('management.service_setup')) - ->url('#')->icon('fas fa-cog') - ->label('service_setup')->permissions(['mng_packages', 'categories', 'mng_groups', 'epg', 'tprofiles', 'folder_watch'])->order(600)); - NavbarRegistry::add((new NavbarItem('management.service_setup.packages')) - ->parent('management.service_setup')->url('packages') - ->label('packages')->permissions(['mng_packages'])->order(10)); - NavbarRegistry::add((new NavbarItem('management.service_setup.categories')) - ->parent('management.service_setup')->url('stream_categories') - ->label('categories')->permissions(['categories'])->order(20)); - NavbarRegistry::add((new NavbarItem('management.service_setup.groups')) - ->parent('management.service_setup')->url('groups') - ->label('groups')->permissions(['mng_groups'])->order(30)); - NavbarRegistry::add((new NavbarItem('management.service_setup.epg')) - ->parent('management.service_setup')->url('epgs') - ->label('epgs')->permissions(['epg'])->order(40)); - NavbarRegistry::add((new NavbarItem('management.service_setup.profiles')) - ->parent('management.service_setup')->url('profiles') - ->label('transcode_profiles')->permissions(['tprofiles'])->order(50)); - // Modules inject at order 60+ + // Access codes + NavbarRegistry::add((new NavbarItem('management.access_codes')) + ->url('#')->icon('fas fa-key') + ->label('', 'Access Codes')->permissions(['add_code'])->order(610)); + NavbarRegistry::add((new NavbarItem('management.access_codes.add')) + ->parent('management.access_codes')->url('code') + ->label('add_access_codes')->permissions(['add_code'])->order(10)); + NavbarRegistry::add((new NavbarItem('management.access_codes.manage')) + ->parent('management.access_codes')->url('codes') + ->label('menage_access_codes')->permissions(['add_code'])->order(20)); - // Access codes - NavbarRegistry::add((new NavbarItem('management.access_codes')) - ->url('#')->icon('fas fa-key') - ->label('', 'Access Codes')->permissions(['add_code'])->order(610)); - NavbarRegistry::add((new NavbarItem('management.access_codes.add')) - ->parent('management.access_codes')->url('code') - ->label('add_access_codes')->permissions(['add_code'])->order(10)); - NavbarRegistry::add((new NavbarItem('management.access_codes.manage')) - ->parent('management.access_codes')->url('codes') - ->label('menage_access_codes')->permissions(['add_code'])->order(20)); + // Security + NavbarRegistry::add((new NavbarItem('management.security')) + ->url('#')->icon('fas fa-shield-alt') + ->label('', 'Security')->permissions(['block_asns', 'block_ips', 'block_isps', 'block_uas', 'add_hmac', 'rtmp', 'manage_mag'])->order(620)); + NavbarRegistry::add((new NavbarItem('management.security.asns')) + ->parent('management.security')->url('asns') + ->label('blocked_asns')->permissions(['block_asns'])->order(10)); + NavbarRegistry::add((new NavbarItem('management.security.ips')) + ->parent('management.security')->url('ips') + ->label('blocked_ips')->permissions(['block_ips'])->order(20)); + NavbarRegistry::add((new NavbarItem('management.security.isps')) + ->parent('management.security')->url('isps') + ->label('blocked_isps')->permissions(['block_isps'])->order(30)); + NavbarRegistry::add((new NavbarItem('management.security.uas')) + ->parent('management.security')->url('useragents') + ->label('blocked_uas')->permissions(['block_uas'])->order(40)); + NavbarRegistry::add((new NavbarItem('management.security.hmac')) + ->parent('management.security')->url('hmacs') + ->label('hmac_keys')->permissions(['add_hmac'])->order(50)); + NavbarRegistry::add((new NavbarItem('management.security.rtmp')) + ->parent('management.security')->url('rtmp_ips') + ->label('rtmp_ips')->permissions(['rtmp'])->order(60)); + NavbarRegistry::add((new NavbarItem('management.security.magscan')) + ->parent('management.security')->url('magscan_settings') + ->label('magscan_settings')->permissions(['manage_mag'])->order(70)); - // Security - NavbarRegistry::add((new NavbarItem('management.security')) - ->url('#')->icon('fas fa-shield-alt') - ->label('', 'Security')->permissions(['block_asns', 'block_ips', 'block_isps', 'block_uas', 'add_hmac', 'rtmp', 'manage_mag'])->order(620)); - NavbarRegistry::add((new NavbarItem('management.security.asns')) - ->parent('management.security')->url('asns') - ->label('blocked_asns')->permissions(['block_asns'])->order(10)); - NavbarRegistry::add((new NavbarItem('management.security.ips')) - ->parent('management.security')->url('ips') - ->label('blocked_ips')->permissions(['block_ips'])->order(20)); - NavbarRegistry::add((new NavbarItem('management.security.isps')) - ->parent('management.security')->url('isps') - ->label('blocked_isps')->permissions(['block_isps'])->order(30)); - NavbarRegistry::add((new NavbarItem('management.security.uas')) - ->parent('management.security')->url('useragents') - ->label('blocked_uas')->permissions(['block_uas'])->order(40)); - NavbarRegistry::add((new NavbarItem('management.security.hmac')) - ->parent('management.security')->url('hmacs') - ->label('hmac_keys')->permissions(['add_hmac'])->order(50)); - NavbarRegistry::add((new NavbarItem('management.security.rtmp')) - ->parent('management.security')->url('rtmp_ips') - ->label('rtmp_ips')->permissions(['rtmp'])->order(60)); - NavbarRegistry::add((new NavbarItem('management.security.magscan')) - ->parent('management.security')->url('magscan_settings') - ->label('magscan_settings')->permissions(['manage_mag'])->order(70)); + NavbarRegistry::add((new NavbarItem('management.tools')) + ->url('#')->icon('fas fa-wrench') + ->label('tools')->permissions(['channel_order', 'fingerprint', 'mass_delete', 'quick_tools', 'rtmp', 'stream_tools'])->order(630)); + NavbarRegistry::add((new NavbarItem('management.tools.channel_order')) + ->parent('management.tools')->url('channel_order') + ->label('channel_order')->permissions(['channel_order'])->desktopOnly()->order(10)); + NavbarRegistry::add((new NavbarItem('management.tools.fingerprint')) + ->parent('management.tools')->url('fingerprint') + ->label('fingerprint')->permissions(['fingerprint'])->order(20)); + NavbarRegistry::add((new NavbarItem('management.tools.mass_delete')) + ->parent('management.tools')->url('mass_delete') + ->label('mass_delete')->permissions(['mass_delete'])->order(30)); + NavbarRegistry::add((new NavbarItem('management.tools.quick_tools')) + ->parent('management.tools')->url('quick_tools') + ->label('quick_tools')->permissions(['quick_tools'])->order(40)); + NavbarRegistry::add((new NavbarItem('management.tools.rtmp_monitor')) + ->parent('management.tools')->url('rtmp_monitor') + ->label('', 'RTMP Monitor')->permissions(['rtmp'])->order(50)); + NavbarRegistry::add((new NavbarItem('management.tools.stream_tools')) + ->parent('management.tools')->url('stream_tools') + ->label('stream_tools')->permissions(['stream_tools'])->order(60)); - NavbarRegistry::add((new NavbarItem('management.tools')) - ->url('#')->icon('fas fa-wrench') - ->label('tools')->permissions(['channel_order', 'fingerprint', 'mass_delete', 'quick_tools', 'rtmp', 'stream_tools'])->order(630)); - NavbarRegistry::add((new NavbarItem('management.tools.channel_order')) - ->parent('management.tools')->url('channel_order') - ->label('channel_order')->permissions(['channel_order'])->desktopOnly()->order(10)); - NavbarRegistry::add((new NavbarItem('management.tools.fingerprint')) - ->parent('management.tools')->url('fingerprint') - ->label('fingerprint')->permissions(['fingerprint'])->order(20)); - NavbarRegistry::add((new NavbarItem('management.tools.mass_delete')) - ->parent('management.tools')->url('mass_delete') - ->label('mass_delete')->permissions(['mass_delete'])->order(30)); - NavbarRegistry::add((new NavbarItem('management.tools.quick_tools')) - ->parent('management.tools')->url('quick_tools') - ->label('quick_tools')->permissions(['quick_tools'])->order(40)); - NavbarRegistry::add((new NavbarItem('management.tools.rtmp_monitor')) - ->parent('management.tools')->url('rtmp_monitor') - ->label('', 'RTMP Monitor')->permissions(['rtmp'])->order(50)); - NavbarRegistry::add((new NavbarItem('management.tools.stream_tools')) - ->parent('management.tools')->url('stream_tools') - ->label('stream_tools')->permissions(['stream_tools'])->order(60)); + // Logs moved to its own top-level tab — see _logs(). - // Logs moved to its own top-level tab — see _logs(). + NavbarRegistry::add((new NavbarItem('management.tickets')) + ->url('tickets')->icon('fas fa-ticket-alt') + ->label('tickets')->permissions(['manage_tickets']) + ->settingDisabled('show_tickets')->order(640)); + } - NavbarRegistry::add((new NavbarItem('management.tickets')) - ->url('tickets')->icon('fas fa-ticket-alt') - ->label('tickets')->permissions(['manage_tickets']) - ->settingDisabled('show_tickets')->order(640)); - } + // ── Profile dropdown ────────────────────────────────────────── - // ── Profile dropdown ────────────────────────────────────────── + /** + * Register the top-right user/profile dropdown items. + * + * These live under the reserved 'profile' parent key. There is + * intentionally no top-level 'profile' NavbarItem, so these never leak + * into the main navigation (NavbarRegistry::getTopLevel()); the admin + * header renders them explicitly via NavbarRegistry::getChildren('profile'). + * + * Modules inject their own entries (e.g. the plex/watch "settings" links + * that used to be hard-coded in header.php) at order 100+, keeping logout + * pinned to the bottom: + * NavbarRegistry::add((new NavbarItem('profile.watch_settings')) + * ->parent('profile')->url('settings_watch') + * ->label('watch_settings')->permissions(['folder_watch_settings'])->order(110)); + * + * @return void + */ + private static function _profile(): void { + NavbarRegistry::add((new NavbarItem('profile.edit')) + ->parent('profile')->url('edit_profile') + ->label('user_profile')->order(10)); - /** - * Register the top-right user/profile dropdown items. - * - * These live under the reserved 'profile' parent key. There is - * intentionally no top-level 'profile' NavbarItem, so these never leak - * into the main navigation (NavbarRegistry::getTopLevel()); the admin - * header renders them explicitly via NavbarRegistry::getChildren('profile'). - * - * Modules inject their own entries (e.g. the plex/watch "settings" links - * that used to be hard-coded in header.php) at order 100+, keeping logout - * pinned to the bottom: - * NavbarRegistry::add((new NavbarItem('profile.watch_settings')) - * ->parent('profile')->url('settings_watch') - * ->label('watch_settings')->permissions(['folder_watch_settings'])->order(110)); - * - * @return void - */ - private static function _profile(): void { - NavbarRegistry::add((new NavbarItem('profile.edit')) - ->parent('profile')->url('edit_profile') - ->label('user_profile')->order(10)); + NavbarRegistry::add((new NavbarItem('profile.settings')) + ->parent('profile')->url('settings') + ->label('general_settings')->permissions(['settings'])->order(20)); - NavbarRegistry::add((new NavbarItem('profile.settings')) - ->parent('profile')->url('settings') - ->label('general_settings')->permissions(['settings'])->order(20)); + NavbarRegistry::add((new NavbarItem('profile.backups')) + ->parent('profile')->url('backups') + ->label('backup_settings')->permissions(['database'])->order(30)); - NavbarRegistry::add((new NavbarItem('profile.backups')) - ->parent('profile')->url('backups') - ->label('backup_settings')->permissions(['database'])->order(30)); + NavbarRegistry::add((new NavbarItem('profile.cache')) + ->parent('profile')->url('cache') + ->label('cache_redis')->permissions(['database'])->order(40)); - NavbarRegistry::add((new NavbarItem('profile.cache')) - ->parent('profile')->url('cache') - ->label('cache_redis')->permissions(['database'])->order(40)); + NavbarRegistry::add((new NavbarItem('profile.modules')) + ->parent('profile')->url('modules') + ->label('', 'Modules')->permissions(['settings'])->order(50)); - NavbarRegistry::add((new NavbarItem('profile.modules')) - ->parent('profile')->url('modules') - ->label('', 'Modules')->permissions(['settings'])->order(50)); + // Reserved slot 100–980 for module-provided profile links. - // Reserved slot 100–980 for module-provided profile links. + NavbarRegistry::add((new NavbarItem('profile.logout_divider')) + ->parent('profile')->makeDivider()->order(990)); - NavbarRegistry::add((new NavbarItem('profile.logout_divider')) - ->parent('profile')->makeDivider()->order(990)); - - NavbarRegistry::add((new NavbarItem('profile.logout')) - ->parent('profile')->url('logout') - ->label('logout')->order(1000)); - } + NavbarRegistry::add((new NavbarItem('profile.logout')) + ->parent('profile')->url('logout') + ->label('logout')->order(1000)); + } } diff --git a/src/Core/Module/MigratableInterface.php b/src/Core/Module/MigratableInterface.php index c44248d6..b22b0ed0 100644 --- a/src/Core/Module/MigratableInterface.php +++ b/src/Core/Module/MigratableInterface.php @@ -30,14 +30,14 @@ use XcVm\Core\Container\ServiceContainer; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface MigratableInterface { - /** - * Return module migrations keyed by target version string. - * - * Keys are semver strings (e.g. "1.1.0"). Values are callables - * that perform the schema or data change for that version step. - * Each callable receives the ServiceContainer — use it to access db, settings, etc. - * - * @return array - */ - public function getMigrations(): array; + /** + * Return module migrations keyed by target version string. + * + * Keys are semver strings (e.g. "1.1.0"). Values are callables + * that perform the schema or data change for that version step. + * Each callable receives the ServiceContainer — use it to access db, settings, etc. + * + * @return array + */ + public function getMigrations(): array; } diff --git a/src/Core/Module/ModuleInterface.php b/src/Core/Module/ModuleInterface.php index 66da3dc5..0a987e0b 100644 --- a/src/Core/Module/ModuleInterface.php +++ b/src/Core/Module/ModuleInterface.php @@ -42,32 +42,31 @@ use XcVm\Core\Module\Contract\ServiceProviderInterface; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ interface ModuleInterface extends - ServiceProviderInterface, - RouteProviderInterface, - CommandProviderInterface, - NavbarProviderInterface -{ - /** - * Unique module name — must match the directory name in modules/. - */ - public function getName(): string; + ServiceProviderInterface, + RouteProviderInterface, + CommandProviderInterface, + NavbarProviderInterface { + /** + * Unique module name — must match the directory name in modules/. + */ + public function getName(): string; - /** - * Module version (semver). - */ - public function getVersion(): string; + /** + * Module version (semver). + */ + public function getVersion(): string; - /** - * Run once when the module is enabled. - * - * Creates tables, seeds initial data. - */ - public function install(): void; + /** + * Run once when the module is enabled. + * + * Creates tables, seeds initial data. + */ + public function install(): void; - /** - * Run once when the module is disabled or removed. - * - * Drops tables, clears settings entries, removes cron records. - */ - public function uninstall(): void; + /** + * Run once when the module is disabled or removed. + * + * Drops tables, clears settings entries, removes cron records. + */ + public function uninstall(): void; } diff --git a/src/Core/Module/ModuleLoader.php b/src/Core/Module/ModuleLoader.php index 6ce499bf..c26d978e 100644 --- a/src/Core/Module/ModuleLoader.php +++ b/src/Core/Module/ModuleLoader.php @@ -49,973 +49,969 @@ use XcVm\Core\Module\Contract\TopbarProviderInterface; */ class ModuleLoader { - /** - * Former modules whose functionality now ships in the core codebase. - * - * Released module archives may still declare them in `dependencies` - * (e.g. watch/plex depend on 'tmdb'); such dependencies are considered - * always satisfied and are stripped during manifest normalization. - * - * @var string[] - */ - public const CORE_PROVIDED_MODULES = ['tmdb']; - - /** @var ModuleInterface[] Loaded module instances, keyed by module name */ - private array $modules = []; - - /** @var array Module overrides from config/modules.php (enabled/disabled, class names) */ - private array $overrides = []; - - /** @var array Normalized manifest data from module.json files (name => manifest array) */ - private array $manifests = []; - - /** @var array Module paths that already have an autoloader registered */ - private static array $autoloadedPaths = []; - - /** - * Loads all discovered modules from modules/ directory. - * - * Performs automatic discovery, dependency validation, load order resolution, - * and environment filtering (main / lb / any). Modules are loaded in topological - * order to satisfy dependencies. - * - * @param string|null $modulesDir Path to modules directory. If null, auto-detected via MAIN_HOME or src/modules. - * @return self Fluent interface for chaining. - * @throws \RuntimeException If a module fails to load, dependency is missing, or manifest is invalid. - */ - public function loadAll(?string $modulesDir = null): self { - if ($modulesDir === null) { - $modulesDir = defined('MAIN_HOME') - ? MAIN_HOME . 'Modules' - : dirname(__DIR__, 2) . '/Modules'; - } - - $this->modules = []; - $this->manifests = []; - - $this->loadOverrides(); - - $jsonFiles = glob($modulesDir . '/*/module.json') ?: []; - - // Also discover modules installed as Composer packages (type: xcvm-module). - // vendor/ is expected to be a sibling of the modules/ directory. - $vendorDir = $this->resolveVendorDir($modulesDir); - if ($vendorDir !== null) { - foreach ($this->collectComposerModuleManifests($vendorDir) as $manifest) { - if (!in_array($manifest, $jsonFiles, true)) { - $jsonFiles[] = $manifest; - } - } - } - - if (empty($jsonFiles)) { - return $this; - } - - sort($jsonFiles, SORT_STRING); - - $currentEnvironment = $this->getCurrentEnvironment(); - $discovered = $this->discoverModules($jsonFiles, $currentEnvironment); - - $loadOrder = $this->resolveLoadOrder($discovered); - - // A single broken module must NOT abort the whole load — that would brick the - // panel and CLI (see the philosophy: "removing a module causes no fatal - // errors"). Log the failure, mark the module failed, and carry on so healthy - // modules still load. loadOrder is topologically sorted, so a failed module's - // dependents (which come later) are skipped too — loading a module without its - // dependency present could fatal at boot. - $failed = []; - foreach ($loadOrder as $name) { - $deps = $discovered[$name]['manifest']['dependencies'] ?? []; - $blockedBy = array_values(array_intersect($deps, $failed)); - if ($blockedBy !== []) { - error_log("ModuleLoader: skipping module '{$name}' — dependency failed to load: " . implode(', ', $blockedBy)); - $failed[] = $name; - continue; - } - - if (!$this->load($name, $discovered[$name]['path'])) { - error_log("ModuleLoader: failed to load module '{$name}' — skipping (panel stays up)"); - $failed[] = $name; - continue; - } - - $this->manifests[$name] = $discovered[$name]['manifest']; - } - - return $this; - } - - /** - * Loads a single module by name. - * - * Resolves the class name from module name (or override config), includes the class file, - * and instantiates the module. Verifies that the module implements ModuleInterface. - * - * @param string $name Module name (directory name in modules/). - * @param string|null $modulePath Full path to module directory. If null, auto-detected via getModulePath(). - * @return bool True if successfully loaded, false if class not found or doesn't implement ModuleInterface. - */ - public function load(string $name, ?string $modulePath = null): bool { - if (isset($this->modules[$name])) { - return true; - } - - if ($modulePath === null) { - $modulePath = $this->getModulePath($name); - } - - // Resolve the class from the module's OWN manifest name, not the passed - // $name. Platform installs use the marketplace slug (e.g. "watch-d2bho") - // as the directory, but the module's class follows its canonical manifest - // name (e.g. "watch" -> XcVm\Module\Watch\WatchModule). This mirrors - // discoverModules(), which already keys class resolution on manifest name. - $className = $this->resolveClassName($this->manifestName($modulePath, $name)); - - // Register a PSR-4 autoloader for this module's namespace before loading, - // so multi-file modules (including encrypted ones) can reference their own - // classes. The base namespace is the module FQCN minus its short class name - // (e.g. XcVm\Module\Watch), mapped onto $modulePath. - $baseNamespace = ($nsPos = strrpos($className, '\\')) !== false ? substr($className, 0, $nsPos) : ''; - $this->registerModuleAutoloader($modulePath, $baseNamespace); - - // NB: class_exists() with autoload=false. The module's main class file is - // at the deterministic path modulePath/{ShortName}.php and is require'd - // below — we must NOT let class_exists() trigger the global autoloader, - // whose miss-path runs a full recursive directory rescan - // (Autoloader::warmCache) on EVERY request (workers are short-lived under - // pm=ondemand), which pegged php-fpm at high CPU. - if (!class_exists($className, false)) { - // Strip namespace prefix — the file lives at modulePath/{ShortName}.php. - // Guard the no-namespace case (strrpos → false) so the first char isn't dropped. - $pos = strrpos($className, '\\'); - $shortName = $pos === false ? $className : substr($className, $pos + 1); - $classFile = $modulePath . '/' . $shortName . '.php'; - if (file_exists($classFile)) { - if ($this->isFileEncrypted($classFile) && !extension_loaded('xcvm_core')) { - error_log("ModuleLoader: module '{$name}' is encrypted but xcvm_core is not loaded"); - return false; - } - require_once $classFile; - } - - if (!class_exists($className, false)) { - error_log("ModuleLoader: class '{$className}' not found for module '{$name}'"); - return false; - } - } - - $module = new $className(); - - if (!$module instanceof ModuleInterface) { - error_log("ModuleLoader: class '{$className}' does not implement ModuleInterface"); - return false; - } - - $this->modules[$name] = $module; - return true; - } - - /** - * Boots all loaded modules in web context. - * - * Checks each sub-interface via instanceof so modules can implement - * only the contracts they need. Core navbar is registered first. - * - * @param ServiceContainer $container Service container for dependency injection. - * @param Router|null $router Optional router for module route registration. - * @param StreamPipeline|null $pipeline Optional stream pipeline for middleware registration. - * @return void - */ - public function bootAll(ServiceContainer $container, ?Router $router = null, ?StreamPipeline $pipeline = null): void { - $navbarRegistry = new NavbarRegistry(); - (new CoreNavbarProvider())->registerNavbar($navbarRegistry); - - // Module topbar contributions are merged on top of Topbar's core literal - // (see XcVm\Core\Util\Topbar::config). Reset so a re-boot in the same - // process (tests/CLI) does not accumulate stale entries. - $topbarRegistry = new TopbarRegistry(); - TopbarRegistry::reset(); - - // Module serverSide table handlers (TableController looks them up for - // non-core ids). Reset so a re-boot does not accumulate stale handlers. - $tableRegistry = new TableRegistry(); - TableRegistry::reset(); - - // Module reseller-permission keys (merged into the group editor catalogue). - $permissionRegistry = new PermissionRegistry(); - PermissionRegistry::reset(); - - // Module one-shot Quick Tools actions (button + handler). - $quickToolsRegistry = new QuickToolsRegistry(); - QuickToolsRegistry::reset(); - - foreach ($this->modules as $module) { - if ($module instanceof ServiceProviderInterface) { - $module->boot($container); - $this->registerEventSubscribers($module, $container); - } - - if ($pipeline !== null) { - $this->registerStreamMiddleware($module, $pipeline); - } - - if ($module instanceof RouteProviderInterface && $router !== null) { - $module->registerRoutes($router); - } - - if ($module instanceof NavbarProviderInterface) { - $module->registerNavbar($navbarRegistry); - } - - if ($module instanceof TopbarProviderInterface) { - $module->registerTopbar($topbarRegistry); - } - - if ($module instanceof TableProviderInterface) { - $module->registerTables($tableRegistry); - } - - if ($module instanceof PermissionProviderInterface) { - $module->registerPermissions($permissionRegistry); - } - - if ($module instanceof QuickToolsProviderInterface) { - $module->registerQuickTools($quickToolsRegistry); - } - } - } - - /** - * Registers CLI commands for all loaded modules. - * - * Only calls registerCommands() on modules implementing CommandProviderInterface. - * Used in CLI context (console.php). - * - * @param CommandRegistry $registry Command registry for registering module commands. - * @return void - */ - public function registerAllCommands(CommandRegistry $registry): void { - foreach ($this->modules as $name => $module) { - if (!$module instanceof CommandProviderInterface) { - continue; - } - try { - $module->registerCommands($registry); - } catch (\Throwable $e) { - // A module with a missing/broken command class (e.g. a partial - // deploy) must not brick EVERY CLI command — skip it, keep the rest. - error_log("ModuleLoader: registerCommands failed for module '{$name}': " . $e->getMessage()); - } - } - } - - /** - * Collects system crontab entries declared by loaded modules. - * - * Iterates over all loaded modules implementing CronProviderInterface and - * assembles ready-to-write crontab lines. Callers (StartupCommand, - * StatusCommand) append these to the crontab without touching module code. - * - * @return string[] Complete crontab lines, each ending with "# XC_VM". - */ - public function collectCronEntries(): array { - if (!defined('PHP_BIN') || !defined('MAIN_HOME')) { - return []; - } - - $lines = []; - foreach ($this->modules as $module) { - if (!$module instanceof CronProviderInterface) { - continue; - } - foreach ($module->getCronEntries() as $expression => $command) { - $lines[] = $expression . ' ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php ' . $command . ' # XC_VM'; - } - } - return $lines; - } - - /** - * Checks whether a module is loaded. - * - * @param string $name Module name to check. - * @return bool True if module is loaded and instantiated, false otherwise. - */ - public function isLoaded(string $name): bool { - return isset($this->modules[$name]); - } - - /** - * Retrieves a loaded module instance by name. - * - * @param string $name Module name. - * @return ModuleInterface|null Module instance if loaded, null otherwise. - */ - public function getModule(string $name): ?ModuleInterface { - return $this->modules[$name] ?? null; - } - - /** - * Retrieves all loaded module instances. - * - * @return ModuleInterface[] Associative array of loaded modules (name => instance). - */ - public function getModules(): array { - return $this->modules; - } - - /** - * Retrieves the normalized manifest for a loaded module. - * - * @param string $name Module name. - * @return array|null Manifest array (name, description, version, requires_core, environment, dependencies, has_navbar, has_settings) if loaded, null otherwise. - */ - public function getManifest(string $name): ?array { - return $this->manifests[$name] ?? null; - } - - /** - * Retrieves all manifests for loaded modules. - * - * @return array Associative array of normalized manifests (name => manifest array). - */ - public function getManifests(): array { - return $this->manifests; - } - - /** - * Constructs the full path to a module directory. - * - * @param string $name Module name. - * @return string Full path to module directory (modules/{name}). - */ - public function getModulePath(string $name): string { - $base = defined('MAIN_HOME') ? MAIN_HOME : dirname(__DIR__, 2) . '/'; - return $base . 'Modules/' . $name; - } - - /** - * Loads module override configuration from config/modules.php. - * - * Overrides can disable modules, override class names, or provide other module-specific settings. - * - * @return void - */ - protected function loadOverrides(): void { - $overridesPath = defined('CONFIG_PATH') - ? CONFIG_PATH . 'modules.php' - : dirname(__DIR__, 2) . '/config/modules.php'; - - if (file_exists($overridesPath)) { - $this->overrides = require $overridesPath; - if (!is_array($this->overrides)) { - $this->overrides = []; - } - } - } - - /** - * Discovers and filters modules based on manifest files and environment. - * - * Reads all module.json manifests, normalizes manifest data, checks override disabling, - * and filters modules to current environment (main/lb). Returns discovered modules - * with their paths and normalized manifest data. - * - * @param array $jsonFiles Array of full paths to module.json files. - * @param ServerEnvironment $currentEnvironment Current server environment. - * @return array Associative array of discovered modules: name => [path, manifest]. - * @throws \RuntimeException If manifest has invalid environment value or JSON is malformed. - */ - protected function discoverModules(array $jsonFiles, ServerEnvironment $currentEnvironment): array { - $discovered = []; - - foreach ($jsonFiles as $jsonFile) { - // Derive a provisional name from directory — used only as fallback in readManifest(). - $dirName = basename(dirname($jsonFile)); - - // Check overrides by directory name before spending I/O on the manifest. - if (!$this->resolveState($dirName)->isLoadable()) { - continue; - } - - $manifest = $this->readManifest($jsonFile, $dirName); - - // Canonical name: always from the manifest (handles module-path subdirs in Composer packages). - $name = $manifest['name']; - - // Re-check overrides by canonical name (in case it differs from directory name). - if (!$this->resolveState($name)->isLoadable()) { - continue; - } - - if (!in_array($manifest['environment'], ['main', 'lb', 'any'], true)) { - throw new ModuleManifestException("ModuleLoader: invalid environment in module.json for module {$name}"); - } - - // Filter by environment: skip if module is for different environment (skip lb-only on main, etc) - if ($manifest['environment'] !== 'any' && $manifest['environment'] !== $currentEnvironment->value) { - continue; - } - - $discovered[$name] = [ - 'path' => dirname($jsonFile), - 'manifest' => $manifest, - ]; - } - - return $discovered; - } - - /** - * Resolves the class name for a module from its name. - * - * Checks for override in config first. If not overridden, converts kebab-case name to PascalCase - * and appends 'Module' suffix. - * - * Example: 'watch-manager' -> 'WatchManagerModule' - * - * @param string $name Module name (kebab-case). - * @return string Resolved class name (PascalCase). - */ - protected function resolveClassName(string $name): string { - if (isset($this->overrides[$name]['class'])) { - $class = $this->overrides[$name]['class']; - // Override must be an FQN. If it's a bare class name, build the FQN using convention. - if (!str_contains($class, '\\')) { - $pascal = implode('', array_map('ucfirst', explode('-', $name))); - return 'XcVm\\Module\\' . $pascal . '\\' . $class; - } - return $class; - } - - // Convert kebab-case to PascalCase: 'my-module' → 'MyModule' - $parts = explode('-', $name); - $pascal = implode('', array_map('ucfirst', $parts)); - - // Return fully-qualified class name: XcVm\Module\{Pascal}\{Pascal}Module - return 'XcVm\\Module\\' . $pascal . '\\' . $pascal . 'Module'; - } - - /** - * The module's canonical name as declared in its module.json ("name"), used - * for class resolution. Falls back to $fallback (the directory/slug) when the - * manifest is missing or has no usable name. This lets a module installed - * under a marketplace slug still resolve to the class the developer authored. - * - * @param string|null $modulePath Module directory (contains module.json). - * @param string $fallback Name to use when the manifest has none. - * @return string Canonical module name (kebab-case). - */ - private function manifestName(?string $modulePath, string $fallback): string { - if ($modulePath === null) { - return $fallback; - } - $jsonFile = $modulePath . '/module.json'; - if (is_file($jsonFile)) { - $meta = json_decode((string) @file_get_contents($jsonFile), true); - if (is_array($meta) && isset($meta['name']) && is_string($meta['name']) && $meta['name'] !== '') { - return $meta['name']; - } - } - return $fallback; - } - - /** - * Detects the current environment (main or load-balancer). - * - * Checks SERVER_TYPE constant. Returns LoadBalancer if set to 'lb' (case-insensitive), else Main. - */ - protected function getCurrentEnvironment(): ServerEnvironment { - if (defined('SERVER_TYPE') && strtolower((string) constant('SERVER_TYPE')) === 'lb') { - return ServerEnvironment::LoadBalancer; - } - return ServerEnvironment::Main; - } - - /** - * Reads and normalizes a module manifest from JSON file. - * - * Parses module.json, validates all fields, normalizes dependency arrays (trim, dedup, sort), - * and fills in default values. Performs strict type checking to catch configuration errors early. - * - * Supported fields: - * dependencies — required; missing dep causes load failure - * optional_dependencies — optional; missing dep is silently skipped - * priority — load order weight (higher = earlier, default 0) - * - * @param string $jsonFile Full path to module.json file. - * @param string $name Module name (used for error messages). - * @return array Normalized manifest. - * @throws \RuntimeException If JSON is invalid, dependencies not array, or dependency names not strings. - */ - protected function readManifest(string $jsonFile, string $name): array { - $raw = @file_get_contents($jsonFile); - $manifest = json_decode((string) $raw, true); - - if (!is_array($manifest)) { - throw new ModuleManifestException("ModuleLoader: invalid JSON in module manifest for module {$name}"); - } - - $normalizeDepArray = function (mixed $raw, string $field) use ($name): array { - if (!is_array($raw)) { - throw new ModuleManifestException("ModuleLoader: {$field} must be array for module {$name}"); - } - $result = []; - foreach ($raw as $dep) { - if (!is_string($dep) || trim($dep) === '') { - throw new ModuleManifestException("ModuleLoader: {$field} names must be non-empty strings for module {$name}"); - } - $result[] = trim($dep); - } - sort($result, SORT_STRING); - return array_values(array_unique($result)); - }; - - return [ - 'name' => $manifest['name'] ?? $name, - 'hash_id' => (string) ($manifest['hash_id'] ?? ''), - 'description' => $manifest['description'] ?? '', - 'version' => $manifest['version'] ?? '', - 'requires_core' => $manifest['requires_core'] ?? '', - 'environment' => strtolower((string) ($manifest['environment'] ?? 'main')), - 'dependencies' => self::filterCoreProvidedDependencies($normalizeDepArray($manifest['dependencies'] ?? [], 'dependencies')), - 'optional_dependencies' => self::filterCoreProvidedDependencies($normalizeDepArray($manifest['optional_dependencies'] ?? [], 'optional_dependencies')), - 'has_navbar' => (bool) ($manifest['has_navbar'] ?? false), - 'has_settings' => (bool) ($manifest['has_settings'] ?? false), - 'priority' => (int) ($manifest['priority'] ?? 0), - 'update' => self::normalizeUpdateBlock($manifest, $manifest['name'] ?? $name), - ]; - } - - /** - * Strip dependencies that are provided by the core codebase. - * - * @param string[] $dependencies Normalized dependency names. - * @return string[] Dependencies that still refer to real modules. - */ - public static function filterCoreProvidedDependencies(array $dependencies): array { - return array_values(array_diff($dependencies, self::CORE_PROVIDED_MODULES)); - } - - /** - * Normalize the optional `update` manifest block — where the module gets its - * updates from. Part of the module-update-source plan (P2). - * - * Shape: - * "update": { - * "source": "bundled | platform | git | url", - * "repository": "https://…", // git - * "channel": "stable | beta", - * "slug": "…", // platform (defaults to name) - * "url": "https://…" // url - * } - * - * Absent or unknown source → `bundled` (files ship with the panel), so every - * existing module keeps working unchanged. NO network is done here — this only - * shapes the declared source for the later fetch/apply phases. - * - * @param array $manifest Raw module.json. - * @param string $name Module name (fallback for slug). - * @return array{source:string,repository:string,channel:string,slug:string,url:string} - */ - public static function normalizeUpdateBlock(array $manifest, string $name): array { - $raw = is_array($manifest['update'] ?? null) ? $manifest['update'] : []; - $source = strtolower(trim((string) ($raw['source'] ?? 'bundled'))); - if (!in_array($source, ['bundled', 'platform', 'git', 'url'], true)) { - $source = 'bundled'; - } - - return [ - 'source' => $source, - 'repository' => trim((string) ($raw['repository'] ?? '')), - 'channel' => strtolower(trim((string) ($raw['channel'] ?? 'stable'))) ?: 'stable', - 'slug' => trim((string) ($raw['slug'] ?? '')) ?: $name, - 'url' => trim((string) ($raw['url'] ?? '')), - ]; - } - - /** - * Resolves deterministic load order of modules based on dependencies. - * - * Uses depth-first search (DFS) with topological sort to order modules such that - * dependencies are loaded before their dependents. Modules are visited in alphabetical order - * for determinism. Detects cyclic dependencies and throws exception. - * - * @param array $discovered Discovered modules (name => [path, manifest]). - * @return array Ordered module names: modules are in dependency-order (dependencies first). - * @throws \RuntimeException If a cyclic dependency is detected. - */ - protected function resolveLoadOrder(array $discovered): array { - $order = []; - $state = []; // 1 = visiting, 2 = visited - - // Drop modules whose required dependencies are unavailable (missing on - // disk, disabled/uninstalled via config/modules.php, or filtered out by - // environment) before sorting. A single unsatisfiable module must not - // abort the whole load: we skip it — and, transitively, anything that - // requires it — with a warning, so the rest of the panel and the CLI keep - // working. Without this, e.g. disabling 'watch' while 'plex' (which - // requires it) stays enabled would throw ModuleNotFoundException out of - // loadAll() and brick both the admin panel and console.php. - $discovered = $this->pruneUnsatisfiableModules($discovered); - - $names = array_keys($discovered); - - // Sort by priority desc, then alphabetically for determinism within same priority - usort($names, function (string $a, string $b) use ($discovered): int { - $pa = $discovered[$a]['manifest']['priority'] ?? 0; - $pb = $discovered[$b]['manifest']['priority'] ?? 0; - if ($pa !== $pb) { - return $pb <=> $pa; // higher priority first - } - return strcmp($a, $b); - }); - - foreach ($names as $name) { - $this->visitDependencyNode($name, $discovered, $state, $order, []); - } - - return $order; - } - - /** - * Removes modules whose required dependencies are not present in the - * discovered set, cascading transitively. - * - * Discovery may legitimately exclude a module (disabled/uninstalled via - * config/modules.php, or filtered out for the current environment). When that - * module is a *required* dependency of another, the dependent cannot load — - * but that is not a fatal condition for the whole panel: we drop the dependent - * (and anything depending on it, in turn) and log a warning, leaving every - * still-satisfiable module loadable. This keeps the admin panel and CLI alive - * instead of throwing ModuleNotFoundException out of loadAll(). - * - * Optional dependencies are ignored here — they are allowed to be absent. - * - * @param array $discovered Discovered modules (name => [path, manifest]). - * @return array Pruned discovered set containing only satisfiable modules. - */ - protected function pruneUnsatisfiableModules(array $discovered): array { - do { - $removed = false; - foreach ($discovered as $name => $info) { - foreach ($info['manifest']['dependencies'] as $dependency) { - if (!isset($discovered[$dependency])) { - error_log( - "ModuleLoader: skipping module '{$name}' — required dependency " - . "'{$dependency}' is not available (missing, disabled, or wrong environment)" - ); - unset($discovered[$name]); - $removed = true; - break; - } - } - } - } while ($removed); - - return $discovered; - } - - /** - * DFS traversal to visit a module and its dependencies in dependency order. - * - * Part of topological sort algorithm. Maintains state machine: - * - 1 = currently visiting (used to detect cycles) - * - 2 = finished visiting - * - * Recursively visits all dependencies before adding module to load order. - * - * @param string $name Module name to visit. - * @param array $discovered Discovered modules (name => [path, manifest]). - * @param array &$state Visit state for each module (1=visiting, 2=visited). - * @param array &$order Load order being built (appends module names). - * @param array $stack Call stack trace (used for cycle error message). - * @return void - * @throws \RuntimeException If cyclic dependency detected or dependency not found in discovered modules. - */ - protected function visitDependencyNode( - string $name, - array $discovered, - array &$state, - array &$order, - array $stack - ): void { - if (isset($state[$name])) { - if ($state[$name] === 2) { - // Already fully visited, skip - return; - } - if ($state[$name] === 1) { - // Currently visiting = cycle detected - $cycle = array_slice($stack, array_search($name, $stack, true) ?: 0); - $cycle[] = $name; - throw new ModuleCycleException('ModuleLoader: cyclic module dependency detected: ' . implode(' -> ', $cycle)); - } - } - - if (!isset($discovered[$name])) { - throw new ModuleLoadException("ModuleLoader: unknown module in dependency graph: {$name}"); - } - - // Mark as currently visiting - $state[$name] = 1; - $stack[] = $name; - - // Required dependencies — throw if missing - foreach ($discovered[$name]['manifest']['dependencies'] as $dependency) { - if (!isset($discovered[$dependency])) { - throw new ModuleNotFoundException("ModuleLoader: module {$name} requires missing dependency {$dependency}"); - } - $this->visitDependencyNode($dependency, $discovered, $state, $order, $stack); - } - - // Optional dependencies — visit only when present, skip silently otherwise - foreach ($discovered[$name]['manifest']['optional_dependencies'] ?? [] as $dependency) { - if (isset($discovered[$dependency])) { - $this->visitDependencyNode($dependency, $discovered, $state, $order, $stack); - } - } - - // Mark as fully visited and add to load order - $state[$name] = 2; - $order[] = $name; - } - - /** - * Registers event subscribers declared by a module. - * - * Two complementary mechanisms are supported and both run on every boot: - * - * 1. getEventSubscribers() — legacy array API: - * ['EventClass' => callable] or ['EventClass' => [callable, $priority]] - * - * 2. #[ListensTo] attribute — declarative PHP 8.1 attribute on public methods: - * #[ListensTo(SomeEvent::class, priority: 10)] - * public function onSome(SomeEvent $e): void { ... } - * - * Both paths are additive — using one does not disable the other. - * - * If the event class named in a #[ListensTo] attribute does not exist at - * registration time the listener is silently skipped (graceful degradation). - * - * @param ServiceProviderInterface $module - * @param ServiceContainer $container - * @return void - */ - private function registerEventSubscribers(ServiceProviderInterface $module, ServiceContainer $container): void { - // Verify the container holds an actual EventDispatcher instance (not just the class name). - // Static calls below route to the same instance via EventDispatcher::getInstance(). - if (!$container->has('events') || !$container->get('events') instanceof EventDispatcher) { - return; - } - - // ── 1. Legacy array API via getEventSubscribers() ───────────────── - $subscribers = $module->getEventSubscribers(); - foreach ($subscribers as $event => $handler) { - if (is_array($handler) && isset($handler[0]) && is_callable($handler[0])) { - // [callable, int $priority] tuple — new PSR-14 style - EventDispatcher::listen($event, $handler[0], $handler[1] ?? 0); - } else { - // Legacy: string event name or class-string, plain callable - EventDispatcher::listen($event, $handler); - } - } - - // ── 2. #[ListensTo] attribute scan via Reflection ───────────────── - $reflection = new \ReflectionClass($module); - foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - $attributes = $method->getAttributes(ListensTo::class); - if (empty($attributes)) { - continue; - } - foreach ($attributes as $attribute) { - /** @var ListensTo $listensTo */ - $listensTo = $attribute->newInstance(); - - // Graceful degradation: skip if the event class is not (yet) loadable. - if (!class_exists($listensTo->eventClass)) { - continue; - } - - EventDispatcher::listen( - $listensTo->eventClass, - [$module, $method->getName()], - $listensTo->priority, - ); - } - } - } - - /** - * Registers stream middleware declared by a module into the pipeline. - * - * Only called for modules implementing StreamMiddlewareProviderInterface. - * - * @param ModuleInterface $module - * @param StreamPipeline $pipeline - * @return void - */ - private function registerStreamMiddleware(ModuleInterface $module, StreamPipeline $pipeline): void { - if (!$module instanceof StreamMiddlewareProviderInterface) { - return; - } - foreach ($module->getStreamMiddleware() as $middleware) { - if ($middleware instanceof StreamMiddlewareInterface) { - $pipeline->pipe($middleware); - } - } - } - - /** - * Registers a PSR-4 autoloader for a single module's namespace (once per path). - * - * The module's base namespace ({$baseNamespace}, e.g. XcVm\Module\Watch) is - * mapped onto its directory ({$modulePath}). The namespace remainder becomes - * the sub-path, exactly per PSR-4: - * - * XcVm\Module\Watch\WatchModule → {modulePath}/WatchModule.php - * XcVm\Module\Watch\Service\WatchService → {modulePath}/Service/WatchService.php - * - * Classes outside the module's namespace (global legacy classes, other - * modules, core) fall through to the next autoloader untouched — no short-name - * glob, so two modules can declare same-named sub-classes without colliding. - * Marketplace slug directories are unaffected: {modulePath} is the real path. - * - * Encrypted files are require'd as-is and transparently decrypted by the - * XC_VM zend_compile_file hook. - */ - private function registerModuleAutoloader(string $modulePath, string $baseNamespace): void { - if ($baseNamespace === '' || isset(self::$autoloadedPaths[$modulePath])) { - return; - } - self::$autoloadedPaths[$modulePath] = true; - - $prefix = $baseNamespace . '\\'; - $prefixLen = strlen($prefix); - - spl_autoload_register(function (string $class) use ($modulePath, $prefix, $prefixLen): void { - // Only resolve classes under this module's namespace; everything else - // falls through to the next autoloader (Composer / legacy scanner). - if (strncmp($class, $prefix, $prefixLen) !== 0) { - return; - } - $relative = str_replace('\\', '/', substr($class, $prefixLen)); - $file = $modulePath . '/' . $relative . '.php'; - if (is_file($file)) { - require_once $file; - } - }); - } - - /** - * Resolve the effective ModuleState for a module from its overrides entry. - * - * Reads both the new 'state' key and the legacy 'enabled' bool key so that - * existing config/modules.php files continue to work without migration. - * - * @param string $name Module name or directory name. - * @return ModuleState - */ - private function resolveState(string $name): ModuleState { - $entry = $this->overrides[$name] ?? null; - if ($entry === null) { - return ModuleState::Enabled; - } - // New key takes precedence. - if (isset($entry['state'])) { - return ModuleState::fromRaw($entry['state']); - } - // Legacy bool key. - if (array_key_exists('enabled', $entry)) { - return ModuleState::fromRaw($entry['enabled']); - } - return ModuleState::Enabled; - } - - /** - * Returns true if the file starts with the XCVM encrypted-file magic bytes. - */ - private function isFileEncrypted(string $path): bool { - $fh = @fopen($path, 'rb'); - if (!$fh) { - return false; - } - $magic = fread($fh, 4); - fclose($fh); - return $magic === "\x58\x43\x56\x4D"; - } - - /** - * Resolves the vendor directory path relative to the modules directory. - * - * The vendor directory is expected to be a sibling of the modules/ directory - * (i.e. at MAIN_HOME/vendor/). Returns null when vendor/composer/ does not exist - * or Composer has not been run. - * - * @param string $modulesDir Absolute path to the modules/ directory. - * @return string|null Absolute path to vendor/, or null if not found. - */ - private function resolveVendorDir(string $modulesDir): ?string { - $vendorDir = dirname($modulesDir) . '/vendor'; - return is_dir($vendorDir . '/composer') ? $vendorDir : null; - } - - /** - * Collects module.json paths from Composer-installed xcvm-module packages. - * - * Reads vendor/composer/installed.json and returns the module.json path for - * every package whose "type" is "xcvm-module". Packages may optionally declare - * a subdirectory for the module root via: - * - * "extra": { "xcvm": { "module-path": "src/" } } - * - * When absent, module.json is expected at the package root. - * - * @param string $vendorDir Absolute path to the vendor/ directory. - * @return string[] Absolute paths to discovered module.json files. - */ - private function collectComposerModuleManifests(string $vendorDir): array { - $installedJson = $vendorDir . '/composer/installed.json'; - $raw = @file_get_contents($installedJson); - if ($raw === false) { - return []; - } - - $data = json_decode($raw, true); - if (!is_array($data)) { - return []; - } - - // Composer 2.x wraps packages in {"packages": [...], "dev": ...} - // Composer 1.x uses a flat array - $packages = isset($data['packages']) && is_array($data['packages']) - ? $data['packages'] - : $data; - - $manifests = []; - foreach ($packages as $package) { - if (!is_array($package) || ($package['type'] ?? '') !== 'xcvm-module') { - continue; - } - - // install-path is relative to vendor/composer/ - $installPath = realpath($vendorDir . '/composer/' . ($package['install-path'] ?? '')); - if ($installPath === false) { - continue; - } - - // Optional subdirectory override - $subPath = trim((string) ($package['extra']['xcvm']['module-path'] ?? ''), '/'); - $moduleDir = $subPath !== '' ? $installPath . '/' . $subPath : $installPath; - $manifestFile = $moduleDir . '/module.json'; - - if (file_exists($manifestFile)) { - $manifests[] = $manifestFile; - } - } - - return $manifests; - } + /** + * Former modules whose functionality now ships in the core codebase. + * + * Released module archives may still declare them in `dependencies` + * (e.g. watch/plex depend on 'tmdb'); such dependencies are considered + * always satisfied and are stripped during manifest normalization. + * + * @var string[] + */ + public const CORE_PROVIDED_MODULES = ['tmdb']; + + /** @var ModuleInterface[] Loaded module instances, keyed by module name */ + private array $modules = []; + + /** @var array Module overrides from config/modules.php (enabled/disabled, class names) */ + private array $overrides = []; + + /** @var array Normalized manifest data from module.json files (name => manifest array) */ + private array $manifests = []; + + /** @var array Module paths that already have an autoloader registered */ + private static array $autoloadedPaths = []; + + /** + * Loads all discovered modules from modules/ directory. + * + * Performs automatic discovery, dependency validation, load order resolution, + * and environment filtering (main / lb / any). Modules are loaded in topological + * order to satisfy dependencies. + * + * @param string|null $modulesDir Path to modules directory. If null, auto-detected via MAIN_HOME or src/modules. + * @return self Fluent interface for chaining. + * @throws \RuntimeException If a module fails to load, dependency is missing, or manifest is invalid. + */ + public function loadAll(?string $modulesDir = null): self { + if ($modulesDir === null) { + $modulesDir = defined('MAIN_HOME') + ? MAIN_HOME . 'Modules' + : dirname(__DIR__, 2) . '/Modules'; + } + + $this->modules = []; + $this->manifests = []; + + $this->loadOverrides(); + + $jsonFiles = glob($modulesDir . '/*/module.json') ?: []; + + // Also discover modules installed as Composer packages (type: xcvm-module). + // vendor/ is expected to be a sibling of the modules/ directory. + $vendorDir = $this->resolveVendorDir($modulesDir); + if ($vendorDir !== null) { + foreach ($this->collectComposerModuleManifests($vendorDir) as $manifest) { + if (!in_array($manifest, $jsonFiles, true)) { + $jsonFiles[] = $manifest; + } + } + } + + if (empty($jsonFiles)) { + return $this; + } + + sort($jsonFiles, SORT_STRING); + + $currentEnvironment = $this->getCurrentEnvironment(); + $discovered = $this->discoverModules($jsonFiles, $currentEnvironment); + + $loadOrder = $this->resolveLoadOrder($discovered); + + // A single broken module must NOT abort the whole load — that would brick the + // panel and CLI (see the philosophy: "removing a module causes no fatal + // errors"). Log the failure, mark the module failed, and carry on so healthy + // modules still load. loadOrder is topologically sorted, so a failed module's + // dependents (which come later) are skipped too — loading a module without its + // dependency present could fatal at boot. + $failed = []; + foreach ($loadOrder as $name) { + $deps = $discovered[$name]['manifest']['dependencies'] ?? []; + $blockedBy = array_values(array_intersect($deps, $failed)); + if ($blockedBy !== []) { + error_log("ModuleLoader: skipping module '{$name}' — dependency failed to load: " . implode(', ', $blockedBy)); + $failed[] = $name; + continue; + } + + if (!$this->load($name, $discovered[$name]['path'])) { + error_log("ModuleLoader: failed to load module '{$name}' — skipping (panel stays up)"); + $failed[] = $name; + continue; + } + + $this->manifests[$name] = $discovered[$name]['manifest']; + } + + return $this; + } + + /** + * Loads a single module by name. + * + * Resolves the class name from module name (or override config), includes the class file, + * and instantiates the module. Verifies that the module implements ModuleInterface. + * + * @param string $name Module name (directory name in modules/). + * @param string|null $modulePath Full path to module directory. If null, auto-detected via getModulePath(). + * @return bool True if successfully loaded, false if class not found or doesn't implement ModuleInterface. + */ + public function load(string $name, ?string $modulePath = null): bool { + if (isset($this->modules[$name])) { + return true; + } + + if ($modulePath === null) { + $modulePath = $this->getModulePath($name); + } + + // Resolve the class from the module's OWN manifest name, not the passed + // $name. Platform installs use the marketplace slug (e.g. "watch-d2bho") + // as the directory, but the module's class follows its canonical manifest + // name (e.g. "watch" -> XcVm\Module\Watch\WatchModule). This mirrors + // discoverModules(), which already keys class resolution on manifest name. + $className = $this->resolveClassName($this->manifestName($modulePath, $name)); + + // Register a PSR-4 autoloader for this module's namespace before loading, + // so multi-file modules (including encrypted ones) can reference their own + // classes. The base namespace is the module FQCN minus its short class name + // (e.g. XcVm\Module\Watch), mapped onto $modulePath. + $baseNamespace = ($nsPos = strrpos($className, '\\')) !== false ? substr($className, 0, $nsPos) : ''; + $this->registerModuleAutoloader($modulePath, $baseNamespace); + + // NB: class_exists() with autoload=false. The module's main class file is + // at the deterministic path modulePath/{ShortName}.php and is require'd + // below — we must NOT let class_exists() trigger the global autoloader, + // whose miss-path runs a full recursive directory rescan + // (Autoloader::warmCache) on EVERY request (workers are short-lived under + // pm=ondemand), which pegged php-fpm at high CPU. + if (!class_exists($className, false)) { + // Strip namespace prefix — the file lives at modulePath/{ShortName}.php. + // Guard the no-namespace case (strrpos → false) so the first char isn't dropped. + $pos = strrpos($className, '\\'); + $shortName = $pos === false ? $className : substr($className, $pos + 1); + $classFile = $modulePath . '/' . $shortName . '.php'; + if (file_exists($classFile)) { + if ($this->isFileEncrypted($classFile) && !extension_loaded('xcvm_core')) { + error_log("ModuleLoader: module '{$name}' is encrypted but xcvm_core is not loaded"); + return false; + } + require_once $classFile; + } + + if (!class_exists($className, false)) { + error_log("ModuleLoader: class '{$className}' not found for module '{$name}'"); + return false; + } + } + + $module = new $className(); + + if (!$module instanceof ModuleInterface) { + error_log("ModuleLoader: class '{$className}' does not implement ModuleInterface"); + return false; + } + + $this->modules[$name] = $module; + return true; + } + + /** + * Boots all loaded modules in web context. + * + * Checks each sub-interface via instanceof so modules can implement + * only the contracts they need. Core navbar is registered first. + * + * @param ServiceContainer $container Service container for dependency injection. + * @param Router|null $router Optional router for module route registration. + * @param StreamPipeline|null $pipeline Optional stream pipeline for middleware registration. + * @return void + */ + public function bootAll(ServiceContainer $container, ?Router $router = null, ?StreamPipeline $pipeline = null): void { + $navbarRegistry = new NavbarRegistry(); + (new CoreNavbarProvider())->registerNavbar($navbarRegistry); + + // Module topbar contributions are merged on top of Topbar's core literal + // (see XcVm\Core\Util\Topbar::config). Reset so a re-boot in the same + // process (tests/CLI) does not accumulate stale entries. + $topbarRegistry = new TopbarRegistry(); + TopbarRegistry::reset(); + + // Module serverSide table handlers (TableController looks them up for + // non-core ids). Reset so a re-boot does not accumulate stale handlers. + $tableRegistry = new TableRegistry(); + TableRegistry::reset(); + + // Module reseller-permission keys (merged into the group editor catalogue). + $permissionRegistry = new PermissionRegistry(); + PermissionRegistry::reset(); + + // Module one-shot Quick Tools actions (button + handler). + $quickToolsRegistry = new QuickToolsRegistry(); + QuickToolsRegistry::reset(); + + foreach ($this->modules as $module) { + if ($module instanceof ServiceProviderInterface) { + $module->boot($container); + $this->registerEventSubscribers($module, $container); + } + + if ($pipeline !== null) { + $this->registerStreamMiddleware($module, $pipeline); + } + + if ($module instanceof RouteProviderInterface && $router !== null) { + $module->registerRoutes($router); + } + + if ($module instanceof NavbarProviderInterface) { + $module->registerNavbar($navbarRegistry); + } + + if ($module instanceof TopbarProviderInterface) { + $module->registerTopbar($topbarRegistry); + } + + if ($module instanceof TableProviderInterface) { + $module->registerTables($tableRegistry); + } + + if ($module instanceof PermissionProviderInterface) { + $module->registerPermissions($permissionRegistry); + } + + if ($module instanceof QuickToolsProviderInterface) { + $module->registerQuickTools($quickToolsRegistry); + } + } + } + + /** + * Registers CLI commands for all loaded modules. + * + * Only calls registerCommands() on modules implementing CommandProviderInterface. + * Used in CLI context (console.php). + * + * @param CommandRegistry $registry Command registry for registering module commands. + * @return void + */ + public function registerAllCommands(CommandRegistry $registry): void { + foreach ($this->modules as $name => $module) { + if (!$module instanceof CommandProviderInterface) { + continue; + } + try { + $module->registerCommands($registry); + } catch (\Throwable $e) { + // A module with a missing/broken command class (e.g. a partial + // deploy) must not brick EVERY CLI command — skip it, keep the rest. + error_log("ModuleLoader: registerCommands failed for module '{$name}': " . $e->getMessage()); + } + } + } + + /** + * Collects system crontab entries declared by loaded modules. + * + * Iterates over all loaded modules implementing CronProviderInterface and + * assembles ready-to-write crontab lines. Callers (StartupCommand, + * StatusCommand) append these to the crontab without touching module code. + * + * @return string[] Complete crontab lines, each ending with "# XC_VM". + */ + public function collectCronEntries(): array { + if (!defined('PHP_BIN') || !defined('MAIN_HOME')) { + return []; + } + + $lines = []; + foreach ($this->modules as $module) { + if (!$module instanceof CronProviderInterface) { + continue; + } + foreach ($module->getCronEntries() as $expression => $command) { + $lines[] = $expression . ' ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php ' . $command . ' # XC_VM'; + } + } + return $lines; + } + + /** + * Checks whether a module is loaded. + * + * @param string $name Module name to check. + * @return bool True if module is loaded and instantiated, false otherwise. + */ + public function isLoaded(string $name): bool { + return isset($this->modules[$name]); + } + + /** + * Retrieves a loaded module instance by name. + * + * @param string $name Module name. + * @return ModuleInterface|null Module instance if loaded, null otherwise. + */ + public function getModule(string $name): ?ModuleInterface { + return $this->modules[$name] ?? null; + } + + /** + * Retrieves all loaded module instances. + * + * @return ModuleInterface[] Associative array of loaded modules (name => instance). + */ + public function getModules(): array { + return $this->modules; + } + + /** + * Retrieves the normalized manifest for a loaded module. + * + * @param string $name Module name. + * @return array|null Manifest array (name, description, version, requires_core, environment, dependencies, has_navbar, has_settings) if loaded, null otherwise. + */ + public function getManifest(string $name): ?array { + return $this->manifests[$name] ?? null; + } + + /** + * Retrieves all manifests for loaded modules. + * + * @return array Associative array of normalized manifests (name => manifest array). + */ + public function getManifests(): array { + return $this->manifests; + } + + /** + * Constructs the full path to a module directory. + * + * @param string $name Module name. + * @return string Full path to module directory (modules/{name}). + */ + public function getModulePath(string $name): string { + $base = defined('MAIN_HOME') ? MAIN_HOME : dirname(__DIR__, 2) . '/'; + return $base . 'Modules/' . $name; + } + + /** + * Loads module override configuration from config/modules.php. + * + * Overrides can disable modules, override class names, or provide other module-specific settings. + * + * @return void + */ + protected function loadOverrides(): void { + $overridesPath = defined('CONFIG_PATH') + ? CONFIG_PATH . 'modules.php' + : dirname(__DIR__, 2) . '/config/modules.php'; + + if (file_exists($overridesPath)) { + $this->overrides = require $overridesPath; + if (!is_array($this->overrides)) { + $this->overrides = []; + } + } + } + + /** + * Discovers and filters modules based on manifest files and environment. + * + * Reads all module.json manifests, normalizes manifest data, checks override disabling, + * and filters modules to current environment (main/lb). Returns discovered modules + * with their paths and normalized manifest data. + * + * @param array $jsonFiles Array of full paths to module.json files. + * @param ServerEnvironment $currentEnvironment Current server environment. + * @return array Associative array of discovered modules: name => [path, manifest]. + * @throws \RuntimeException If manifest has invalid environment value or JSON is malformed. + */ + protected function discoverModules(array $jsonFiles, ServerEnvironment $currentEnvironment): array { + $discovered = []; + + foreach ($jsonFiles as $jsonFile) { + // Derive a provisional name from directory — used only as fallback in readManifest(). + $dirName = basename(dirname($jsonFile)); + + // Check overrides by directory name before spending I/O on the manifest. + if (!$this->resolveState($dirName)->isLoadable()) { + continue; + } + + $manifest = $this->readManifest($jsonFile, $dirName); + + // Canonical name: always from the manifest (handles module-path subdirs in Composer packages). + $name = $manifest['name']; + + // Re-check overrides by canonical name (in case it differs from directory name). + if (!$this->resolveState($name)->isLoadable()) { + continue; + } + + if (!in_array($manifest['environment'], ['main', 'lb', 'any'], true)) { + throw new ModuleManifestException("ModuleLoader: invalid environment in module.json for module {$name}"); + } + + // Filter by environment: skip if module is for different environment (skip lb-only on main, etc) + if ($manifest['environment'] !== 'any' && $manifest['environment'] !== $currentEnvironment->value) { + continue; + } + + $discovered[$name] = [ + 'path' => dirname($jsonFile), + 'manifest' => $manifest, + ]; + } + + return $discovered; + } + + /** + * Resolves the class name for a module from its name. + * + * Checks for override in config first. If not overridden, converts kebab-case name to PascalCase + * and appends 'Module' suffix. + * + * Example: 'watch-manager' -> 'WatchManagerModule' + * + * @param string $name Module name (kebab-case). + * @return string Resolved class name (PascalCase). + */ + protected function resolveClassName(string $name): string { + if (isset($this->overrides[$name]['class'])) { + $class = $this->overrides[$name]['class']; + // Override must be an FQN. If it's a bare class name, build the FQN using convention. + if (!str_contains($class, '\\')) { + $pascal = implode('', array_map('ucfirst', explode('-', $name))); + return 'XcVm\\Module\\' . $pascal . '\\' . $class; + } + return $class; + } + + // Convert kebab-case to PascalCase: 'my-module' → 'MyModule' + $parts = explode('-', $name); + $pascal = implode('', array_map('ucfirst', $parts)); + + // Return fully-qualified class name: XcVm\Module\{Pascal}\{Pascal}Module + return 'XcVm\\Module\\' . $pascal . '\\' . $pascal . 'Module'; + } + + /** + * The module's canonical name as declared in its module.json ("name"), used + * for class resolution. Falls back to $fallback (the directory/slug) when the + * manifest is missing or has no usable name. This lets a module installed + * under a marketplace slug still resolve to the class the developer authored. + * + * @param string|null $modulePath Module directory (contains module.json). + * @param string $fallback Name to use when the manifest has none. + * @return string Canonical module name (kebab-case). + */ + private function manifestName(?string $modulePath, string $fallback): string { + if ($modulePath === null) { + return $fallback; + } + $jsonFile = $modulePath . '/module.json'; + if (is_file($jsonFile)) { + $meta = json_decode((string) @file_get_contents($jsonFile), true); + if (is_array($meta) && isset($meta['name']) && is_string($meta['name']) && $meta['name'] !== '') { + return $meta['name']; + } + } + return $fallback; + } + + /** + * Detects the current environment (main or load-balancer). + * + * Checks SERVER_TYPE constant. Returns LoadBalancer if set to 'lb' (case-insensitive), else Main. + */ + protected function getCurrentEnvironment(): ServerEnvironment { + if (defined('SERVER_TYPE') && strtolower((string) constant('SERVER_TYPE')) === 'lb') { + return ServerEnvironment::LoadBalancer; + } + return ServerEnvironment::Main; + } + + /** + * Reads and normalizes a module manifest from JSON file. + * + * Parses module.json, validates all fields, normalizes dependency arrays (trim, dedup, sort), + * and fills in default values. Performs strict type checking to catch configuration errors early. + * + * Supported fields: + * dependencies — required; missing dep causes load failure + * optional_dependencies — optional; missing dep is silently skipped + * priority — load order weight (higher = earlier, default 0) + * + * @param string $jsonFile Full path to module.json file. + * @param string $name Module name (used for error messages). + * @return array Normalized manifest. + * @throws \RuntimeException If JSON is invalid, dependencies not array, or dependency names not strings. + */ + protected function readManifest(string $jsonFile, string $name): array { + $raw = @file_get_contents($jsonFile); + $manifest = json_decode((string) $raw, true); + + if (!is_array($manifest)) { + throw new ModuleManifestException("ModuleLoader: invalid JSON in module manifest for module {$name}"); + } + + $normalizeDepArray = function (mixed $raw, string $field) use ($name): array { + if (!is_array($raw)) { + throw new ModuleManifestException("ModuleLoader: {$field} must be array for module {$name}"); + } + $result = []; + foreach ($raw as $dep) { + if (!is_string($dep) || trim($dep) === '') { + throw new ModuleManifestException("ModuleLoader: {$field} names must be non-empty strings for module {$name}"); + } + $result[] = trim($dep); + } + sort($result, SORT_STRING); + return array_values(array_unique($result)); + }; + + return [ + 'name' => $manifest['name'] ?? $name, + 'hash_id' => (string) ($manifest['hash_id'] ?? ''), + 'description' => $manifest['description'] ?? '', + 'version' => $manifest['version'] ?? '', + 'requires_core' => $manifest['requires_core'] ?? '', + 'environment' => strtolower((string) ($manifest['environment'] ?? 'main')), + 'dependencies' => self::filterCoreProvidedDependencies($normalizeDepArray($manifest['dependencies'] ?? [], 'dependencies')), + 'optional_dependencies' => self::filterCoreProvidedDependencies($normalizeDepArray($manifest['optional_dependencies'] ?? [], 'optional_dependencies')), + 'has_navbar' => (bool) ($manifest['has_navbar'] ?? false), + 'has_settings' => (bool) ($manifest['has_settings'] ?? false), + 'priority' => (int) ($manifest['priority'] ?? 0), + 'update' => self::normalizeUpdateBlock($manifest, $manifest['name'] ?? $name), + ]; + } + + /** + * Strip dependencies that are provided by the core codebase. + * + * @param string[] $dependencies Normalized dependency names. + * @return string[] Dependencies that still refer to real modules. + */ + public static function filterCoreProvidedDependencies(array $dependencies): array { + return array_values(array_diff($dependencies, self::CORE_PROVIDED_MODULES)); + } + + /** + * Normalize the optional `update` manifest block — where the module gets its + * updates from. Part of the module-update-source plan (P2). + * + * Shape: + * "update": { + * "source": "bundled | platform | git | url", + * "repository": "https://…", // git + * "channel": "stable | beta", + * "slug": "…", // platform (defaults to name) + * "url": "https://…" // url + * } + * + * Absent or unknown source → `bundled` (files ship with the panel), so every + * existing module keeps working unchanged. NO network is done here — this only + * shapes the declared source for the later fetch/apply phases. + * + * @param array $manifest Raw module.json. + * @param string $name Module name (fallback for slug). + * @return array{source:string,repository:string,channel:string,slug:string,url:string} + */ + public static function normalizeUpdateBlock(array $manifest, string $name): array { + $raw = is_array($manifest['update'] ?? null) ? $manifest['update'] : []; + $source = strtolower(trim((string) ($raw['source'] ?? 'bundled'))); + if (!in_array($source, ['bundled', 'platform', 'git', 'url'], true)) { + $source = 'bundled'; + } + + return [ + 'source' => $source, + 'repository' => trim((string) ($raw['repository'] ?? '')), + 'channel' => strtolower(trim((string) ($raw['channel'] ?? 'stable'))) ?: 'stable', + 'slug' => trim((string) ($raw['slug'] ?? '')) ?: $name, + 'url' => trim((string) ($raw['url'] ?? '')), + ]; + } + + /** + * Resolves deterministic load order of modules based on dependencies. + * + * Uses depth-first search (DFS) with topological sort to order modules such that + * dependencies are loaded before their dependents. Modules are visited in alphabetical order + * for determinism. Detects cyclic dependencies and throws exception. + * + * @param array $discovered Discovered modules (name => [path, manifest]). + * @return array Ordered module names: modules are in dependency-order (dependencies first). + * @throws \RuntimeException If a cyclic dependency is detected. + */ + protected function resolveLoadOrder(array $discovered): array { + $order = []; + $state = []; // 1 = visiting, 2 = visited + + // Drop modules whose required dependencies are unavailable (missing on + // disk, disabled/uninstalled via config/modules.php, or filtered out by + // environment) before sorting. A single unsatisfiable module must not + // abort the whole load: we skip it — and, transitively, anything that + // requires it — with a warning, so the rest of the panel and the CLI keep + // working. Without this, e.g. disabling 'watch' while 'plex' (which + // requires it) stays enabled would throw ModuleNotFoundException out of + // loadAll() and brick both the admin panel and console.php. + $discovered = $this->pruneUnsatisfiableModules($discovered); + + $names = array_keys($discovered); + + // Sort by priority desc, then alphabetically for determinism within same priority + usort($names, function (string $a, string $b) use ($discovered): int { + $pa = $discovered[$a]['manifest']['priority'] ?? 0; + $pb = $discovered[$b]['manifest']['priority'] ?? 0; + if ($pa !== $pb) { + return $pb <=> $pa; // higher priority first + } + return strcmp($a, $b); + }); + + foreach ($names as $name) { + $this->visitDependencyNode($name, $discovered, $state, $order, []); + } + + return $order; + } + + /** + * Removes modules whose required dependencies are not present in the + * discovered set, cascading transitively. + * + * Discovery may legitimately exclude a module (disabled/uninstalled via + * config/modules.php, or filtered out for the current environment). When that + * module is a *required* dependency of another, the dependent cannot load — + * but that is not a fatal condition for the whole panel: we drop the dependent + * (and anything depending on it, in turn) and log a warning, leaving every + * still-satisfiable module loadable. This keeps the admin panel and CLI alive + * instead of throwing ModuleNotFoundException out of loadAll(). + * + * Optional dependencies are ignored here — they are allowed to be absent. + * + * @param array $discovered Discovered modules (name => [path, manifest]). + * @return array Pruned discovered set containing only satisfiable modules. + */ + protected function pruneUnsatisfiableModules(array $discovered): array { + do { + $removed = false; + foreach ($discovered as $name => $info) { + foreach ($info['manifest']['dependencies'] as $dependency) { + if (!isset($discovered[$dependency])) { + error_log( + "ModuleLoader: skipping module '{$name}' — required dependency " + . "'{$dependency}' is not available (missing, disabled, or wrong environment)" + ); + unset($discovered[$name]); + $removed = true; + break; + } + } + } + } while ($removed); + + return $discovered; + } + + /** + * DFS traversal to visit a module and its dependencies in dependency order. + * + * Part of topological sort algorithm. Maintains state machine: + * - 1 = currently visiting (used to detect cycles) + * - 2 = finished visiting + * + * Recursively visits all dependencies before adding module to load order. + * + * @param string $name Module name to visit. + * @param array $discovered Discovered modules (name => [path, manifest]). + * @param array &$state Visit state for each module (1=visiting, 2=visited). + * @param array &$order Load order being built (appends module names). + * @param array $stack Call stack trace (used for cycle error message). + * @return void + * @throws \RuntimeException If cyclic dependency detected or dependency not found in discovered modules. + */ + protected function visitDependencyNode( + string $name, + array $discovered, + array &$state, + array &$order, + array $stack + ): void { + if (isset($state[$name])) { + if ($state[$name] === 2) { + // Already fully visited, skip + return; + } + if ($state[$name] === 1) { + // Currently visiting = cycle detected + $cycle = array_slice($stack, array_search($name, $stack, true) ?: 0); + $cycle[] = $name; + throw new ModuleCycleException('ModuleLoader: cyclic module dependency detected: ' . implode(' -> ', $cycle)); + } + } + + if (!isset($discovered[$name])) { + throw new ModuleLoadException("ModuleLoader: unknown module in dependency graph: {$name}"); + } + + // Mark as currently visiting + $state[$name] = 1; + $stack[] = $name; + + // Required dependencies — throw if missing + foreach ($discovered[$name]['manifest']['dependencies'] as $dependency) { + if (!isset($discovered[$dependency])) { + throw new ModuleNotFoundException("ModuleLoader: module {$name} requires missing dependency {$dependency}"); + } + $this->visitDependencyNode($dependency, $discovered, $state, $order, $stack); + } + + // Optional dependencies — visit only when present, skip silently otherwise + foreach ($discovered[$name]['manifest']['optional_dependencies'] ?? [] as $dependency) { + if (isset($discovered[$dependency])) { + $this->visitDependencyNode($dependency, $discovered, $state, $order, $stack); + } + } + + // Mark as fully visited and add to load order + $state[$name] = 2; + $order[] = $name; + } + + /** + * Registers event subscribers declared by a module. + * + * Two complementary mechanisms are supported and both run on every boot: + * + * 1. getEventSubscribers() — legacy array API: + * ['EventClass' => callable] or ['EventClass' => [callable, $priority]] + * + * 2. #[ListensTo] attribute — declarative PHP 8.1 attribute on public methods: + * #[ListensTo(SomeEvent::class, priority: 10)] + * public function onSome(SomeEvent $e): void { ... } + * + * Both paths are additive — using one does not disable the other. + * + * If the event class named in a #[ListensTo] attribute does not exist at + * registration time the listener is silently skipped (graceful degradation). + * + * @return void + */ + private function registerEventSubscribers(ServiceProviderInterface $module, ServiceContainer $container): void { + // Verify the container holds an actual EventDispatcher instance (not just the class name). + // Static calls below route to the same instance via EventDispatcher::getInstance(). + if (!$container->has('events') || !$container->get('events') instanceof EventDispatcher) { + return; + } + + // ── 1. Legacy array API via getEventSubscribers() ───────────────── + $subscribers = $module->getEventSubscribers(); + foreach ($subscribers as $event => $handler) { + if (is_array($handler) && isset($handler[0]) && is_callable($handler[0])) { + // [callable, int $priority] tuple — new PSR-14 style + EventDispatcher::listen($event, $handler[0], $handler[1] ?? 0); + } else { + // Legacy: string event name or class-string, plain callable + EventDispatcher::listen($event, $handler); + } + } + + // ── 2. #[ListensTo] attribute scan via Reflection ───────────────── + $reflection = new \ReflectionClass($module); + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + $attributes = $method->getAttributes(ListensTo::class); + if (empty($attributes)) { + continue; + } + foreach ($attributes as $attribute) { + /** @var ListensTo $listensTo */ + $listensTo = $attribute->newInstance(); + + // Graceful degradation: skip if the event class is not (yet) loadable. + if (!class_exists($listensTo->eventClass)) { + continue; + } + + EventDispatcher::listen( + $listensTo->eventClass, + [$module, $method->getName()], + $listensTo->priority, + ); + } + } + } + + /** + * Registers stream middleware declared by a module into the pipeline. + * + * Only called for modules implementing StreamMiddlewareProviderInterface. + * + * @return void + */ + private function registerStreamMiddleware(ModuleInterface $module, StreamPipeline $pipeline): void { + if (!$module instanceof StreamMiddlewareProviderInterface) { + return; + } + foreach ($module->getStreamMiddleware() as $middleware) { + if ($middleware instanceof StreamMiddlewareInterface) { + $pipeline->pipe($middleware); + } + } + } + + /** + * Registers a PSR-4 autoloader for a single module's namespace (once per path). + * + * The module's base namespace ({$baseNamespace}, e.g. XcVm\Module\Watch) is + * mapped onto its directory ({$modulePath}). The namespace remainder becomes + * the sub-path, exactly per PSR-4: + * + * XcVm\Module\Watch\WatchModule → {modulePath}/WatchModule.php + * XcVm\Module\Watch\Service\WatchService → {modulePath}/Service/WatchService.php + * + * Classes outside the module's namespace (global legacy classes, other + * modules, core) fall through to the next autoloader untouched — no short-name + * glob, so two modules can declare same-named sub-classes without colliding. + * Marketplace slug directories are unaffected: {modulePath} is the real path. + * + * Encrypted files are require'd as-is and transparently decrypted by the + * XC_VM zend_compile_file hook. + */ + private function registerModuleAutoloader(string $modulePath, string $baseNamespace): void { + if ($baseNamespace === '' || isset(self::$autoloadedPaths[$modulePath])) { + return; + } + self::$autoloadedPaths[$modulePath] = true; + + $prefix = $baseNamespace . '\\'; + $prefixLen = strlen($prefix); + + spl_autoload_register(function (string $class) use ($modulePath, $prefix, $prefixLen): void { + // Only resolve classes under this module's namespace; everything else + // falls through to the next autoloader (Composer / legacy scanner). + if (strncmp($class, $prefix, $prefixLen) !== 0) { + return; + } + $relative = str_replace('\\', '/', substr($class, $prefixLen)); + $file = $modulePath . '/' . $relative . '.php'; + if (is_file($file)) { + require_once $file; + } + }); + } + + /** + * Resolve the effective ModuleState for a module from its overrides entry. + * + * Reads both the new 'state' key and the legacy 'enabled' bool key so that + * existing config/modules.php files continue to work without migration. + * + * @param string $name Module name or directory name. + * @return ModuleState + */ + private function resolveState(string $name): ModuleState { + $entry = $this->overrides[$name] ?? null; + if ($entry === null) { + return ModuleState::Enabled; + } + // New key takes precedence. + if (isset($entry['state'])) { + return ModuleState::fromRaw($entry['state']); + } + // Legacy bool key. + if (array_key_exists('enabled', $entry)) { + return ModuleState::fromRaw($entry['enabled']); + } + return ModuleState::Enabled; + } + + /** + * Returns true if the file starts with the XCVM encrypted-file magic bytes. + */ + private function isFileEncrypted(string $path): bool { + $fh = @fopen($path, 'rb'); + if (!$fh) { + return false; + } + $magic = fread($fh, 4); + fclose($fh); + return $magic === "\x58\x43\x56\x4D"; + } + + /** + * Resolves the vendor directory path relative to the modules directory. + * + * The vendor directory is expected to be a sibling of the modules/ directory + * (i.e. at MAIN_HOME/vendor/). Returns null when vendor/composer/ does not exist + * or Composer has not been run. + * + * @param string $modulesDir Absolute path to the modules/ directory. + * @return string|null Absolute path to vendor/, or null if not found. + */ + private function resolveVendorDir(string $modulesDir): ?string { + $vendorDir = dirname($modulesDir) . '/vendor'; + return is_dir($vendorDir . '/composer') ? $vendorDir : null; + } + + /** + * Collects module.json paths from Composer-installed xcvm-module packages. + * + * Reads vendor/composer/installed.json and returns the module.json path for + * every package whose "type" is "xcvm-module". Packages may optionally declare + * a subdirectory for the module root via: + * + * "extra": { "xcvm": { "module-path": "src/" } } + * + * When absent, module.json is expected at the package root. + * + * @param string $vendorDir Absolute path to the vendor/ directory. + * @return string[] Absolute paths to discovered module.json files. + */ + private function collectComposerModuleManifests(string $vendorDir): array { + $installedJson = $vendorDir . '/composer/installed.json'; + $raw = @file_get_contents($installedJson); + if ($raw === false) { + return []; + } + + $data = json_decode($raw, true); + if (!is_array($data)) { + return []; + } + + // Composer 2.x wraps packages in {"packages": [...], "dev": ...} + // Composer 1.x uses a flat array + $packages = isset($data['packages']) && is_array($data['packages']) + ? $data['packages'] + : $data; + + $manifests = []; + foreach ($packages as $package) { + if (!is_array($package) || ($package['type'] ?? '') !== 'xcvm-module') { + continue; + } + + // install-path is relative to vendor/composer/ + $installPath = realpath($vendorDir . '/composer/' . ($package['install-path'] ?? '')); + if ($installPath === false) { + continue; + } + + // Optional subdirectory override + $subPath = trim((string) ($package['extra']['xcvm']['module-path'] ?? ''), '/'); + $moduleDir = $subPath !== '' ? $installPath . '/' . $subPath : $installPath; + $manifestFile = $moduleDir . '/module.json'; + + if (file_exists($manifestFile)) { + $manifests[] = $manifestFile; + } + } + + return $manifests; + } } diff --git a/src/Core/Module/ModuleManager.php b/src/Core/Module/ModuleManager.php index 42e8c983..e8f2caa6 100644 --- a/src/Core/Module/ModuleManager.php +++ b/src/Core/Module/ModuleManager.php @@ -31,2213 +31,2217 @@ use XcVm\Infrastructure\Database\DatabaseFactory; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class ModuleManager { - private string $modulesPath; - private string $overridesPath; - private string $archivesPath; - private ?ServiceContainer $container; - - /** - * Initialize the module manager. - * - * @param string|null $modulesPath Path to the modules directory. - * @param string|null $overridesPath Path to the config/modules.php overrides file. - * @param ServiceContainer|null $container Service container for DB access and DI. - */ - public function __construct( - ?string $modulesPath = null, - ?string $overridesPath = null, - ?ServiceContainer $container = null - ) { - $this->modulesPath = $modulesPath ?: (defined('MAIN_HOME') ? MAIN_HOME . 'Modules' : dirname(__DIR__, 2) . '/Modules'); - $this->overridesPath = $overridesPath ?: (defined('CONFIG_PATH') ? CONFIG_PATH . 'modules.php' : dirname(__DIR__, 2) . '/config/modules.php'); - $this->archivesPath = defined('MAIN_HOME') ? MAIN_HOME . 'modules_archives' : dirname(__DIR__, 2) . '/modules_archives'; - $this->container = $container; - } - - /** - * Absolute path of the stored archive for a custom (non-store) module. - * - * MAIN keeps a copy of every uploaded module archive named - * {name}_{version}.zip so LB servers can pull it back over the internal - * system API (action=getFile). Because every server shares the same - * MAIN_HOME layout, the LB can reconstruct this exact path from the name + - * version carried in the install_module signal. - */ - public function archivePathFor(string $name, string $version): string { - $name = $this->sanitizeModuleName($name); - $version = preg_replace('/[^0-9A-Za-z._\-]/', '', (string) $version); - return $this->archivesPath . '/' . $name . '_' . $version . '.zip'; - } - - /** @return object|null Database instance from the container, or null if unavailable. */ - private function getDb(): ?object { - if ($this->container !== null && $this->container->has('db')) { - return $this->container->get('db'); - } - return null; - } - - /** - * Absolute path of a module's directory on disk, resolving the - * `{name}_{hash5}` directory convention. - * - * A module's logical name (config key, class namespace) never carries the hash - * — sanitizeModuleName() forbids `_`. The directory does: `{name}_{hash5}` (5 - * hex chars of hash_id), so two modules that share a name don't clash on disk. - * Resolution order: exact `{name}` (legacy / back-compat) → the first - * `{name}_*` directory that has a module.json → fall back to exact. - */ - private function modulePathFor(string $name): string { - $name = $this->sanitizeModuleName($name); - $exact = $this->modulesPath . '/' . $name; - if (is_dir($exact)) { - return $exact; - } - foreach (glob($this->modulesPath . '/' . $name . '_*', GLOB_ONLYDIR) ?: [] as $dir) { - if (is_file($dir . '/module.json')) { - return $dir; - } - } - return $exact; - } - - /** - * Directory name for a module: `{name}_{hash5}` where hash5 is the first 5 hex - * chars of its permanent hash_id. The name itself never contains `_`. - * - * A hash_id is mandatory — callers must run the manifest through ensureHashId() - * first so no module is ever placed in a hash-less directory. Passing an empty - * hash_id is a programming error and throws. - */ - private function moduleDirName(string $name, string $hashId): string { - $name = $this->sanitizeModuleName($name); - $hashId = strtolower(preg_replace('/[^a-f0-9]/i', '', $hashId) ?? ''); - if ($hashId === '') { - throw new \RuntimeException("module '{$name}' has no hash_id — cannot build directory name"); - } - return $name . '_' . substr($hashId, 0, 5); - } - - /** - * Ensure the module at $moduleDir has a permanent hash_id, generating and - * persisting one into its module.json when absent. Returns the 32-hex value. - * - * Every module must carry a hash_id: it forms the `{name}_{hash5}` directory - * suffix and is the module's stable identity. Uploaded or legacy modules that - * ship without one get a fresh random id written here (once, mirroring - * tools/gen-module-hashes.php); a valid existing id is immutable and left as-is. - */ - private function ensureHashId(string $moduleDir): string { - $file = $moduleDir . '/module.json'; - $meta = json_decode((string) @file_get_contents($file), true); - if (!is_array($meta)) { - $meta = []; - } - $hash = strtolower(preg_replace('/[^a-f0-9]/i', '', (string) ($meta['hash_id'] ?? '')) ?? ''); - - if (strlen($hash) < 32) { - $hash = bin2hex(random_bytes(16)); - $meta = $this->withHashIdAfterName($meta, $hash); - @file_put_contents( - $file, - json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n" - ); - } - return $hash; - } - - /** - * Return $meta with hash_id set immediately after the `name` key (or first when - * there is no name), dropping any pre-existing empty hash_id entry. - * - * @param array $meta - * @return array - */ - private function withHashIdAfterName(array $meta, string $hash): array { - $out = []; - foreach ($meta as $k => $v) { - if ($k === 'hash_id') { - continue; - } - $out[$k] = $v; - if ($k === 'name') { - $out['hash_id'] = $hash; - } - } - if (!isset($out['hash_id'])) { - $out = ['hash_id' => $hash] + $out; - } - return $out; - } - - /** - * Copy an extracted module directory into modulesPath as `{name}_{hash5}`, - * replacing any existing install of the same module (one per name is active). - * - * @param string $moduleDir Extracted directory containing module.json. - * @return string Canonical module name that was placed. - */ - private function placeModuleFiles(string $moduleDir): string { - $name = $this->sanitizeModuleName($this->manifestNameFromDir($moduleDir)); - // Guarantee a hash_id (generating + persisting one when the upload lacks it) - // so the module is always placed in a `{name}_{hash5}` directory, never bare. - $hash = $this->ensureHashId($moduleDir); - - // Remove any current install of the same module (may live under a different - // {name}_{hash5} or a legacy {name} directory). - $existing = $this->modulePathFor($name); - if (is_dir($existing)) { - $this->deleteDirectory($existing); - } - - $targetDir = $this->modulesPath . '/' . $this->moduleDirName($name, $hash); - if (is_dir($targetDir)) { - $this->deleteDirectory($targetDir); - } - $this->copyDirectory($moduleDir, $targetDir); - - return $name; - } - - /** - * Retire legacy hash-less module directories: rename every bare `{name}` - * directory to the canonical `{name}_{hash5}`, generating a hash_id when the - * manifest lacks one. - * - * Older deployments (and modules placed before the hash convention) live in a - * bare `{name}` directory. This one-shot, idempotent migration — run on every - * `status` pass before anything scans the modules folder — brings them onto the - * `{name}_{hash5}` scheme so the legacy layout can be dropped. Already-hashed - * directories and non-module directories are skipped. When both a bare and a - * hashed copy of the same module exist, the stale bare copy is removed. - * - * @return string[] Canonical names of modules migrated this pass. - */ - public function migrateLegacyModuleDirs(): array { - $migrated = []; - foreach (glob($this->modulesPath . '/*', GLOB_ONLYDIR) ?: [] as $dir) { - if (!is_file($dir . '/module.json')) { - continue; - } - $name = $this->sanitizeModuleName($this->manifestNameFromDir($dir)); - if ($name === '') { - continue; - } - // A canonical `{name}_{hash5}` directory has a basename different from the - // logical name; only a bare `{name}` directory is legacy. - if (basename($dir) !== $name) { - continue; - } - - $hash = $this->ensureHashId($dir); - $target = $this->modulesPath . '/' . $this->moduleDirName($name, $hash); - if ($target === $dir) { - continue; - } - if (is_dir($target)) { - // A hashed copy already exists — the bare directory is a stale dup. - $this->deleteDirectory($dir); - } else { - @rename($dir, $target); - } - $migrated[] = $name; - } - return $migrated; - } - - /** - * Remove on-disk copies and recorded state of modules that are now part of - * the core codebase (ModuleLoader::CORE_PROVIDED_MODULES). - * - * Upgraded panels may still carry the old module directory (any hash - * suffix); left in place it would boot alongside the core implementation - * and its commands/routes would collide. - * - * @return string[] Names that were purged. - */ - public function purgeCoreProvidedModules(): array { - $purged = []; - foreach (ModuleLoader::CORE_PROVIDED_MODULES as $name) { - $dir = $this->modulePathFor($name); - if (is_dir($dir) && is_file($dir . '/module.json')) { - $this->deleteDirectory($dir); - error_log("purgeCoreProvidedModules: removed stale module directory '" . basename($dir) . "' — '{$name}' now ships in core."); - $purged[] = $name; - } - - $overrides = $this->readOverrides(); - if (isset($overrides[$name])) { - unset($overrides[$name]); - $this->writeOverrides($overrides); - if (!in_array($name, $purged, true)) { - $purged[] = $name; - } - } - } - return $purged; - } - - /** - * Names of currently-installed modules that declare $name as a dependency. - * - * @return string[] - */ - private function installedDependentsOf(string $name): array { - $out = []; - foreach ($this->listModules() as $module) { - if (($module['installed_version'] ?? '') === '') { - continue; // not installed — its requirements don't apply - } - if (in_array($name, $module['dependencies'] ?? [], true)) { - $out[] = $module['name']; - } - } - return $out; - } - - /** - * Names of currently-loadable (enabled) installed modules that declare $name - * as a required dependency. - * - * Used to guard disabling: a dependent that is itself disabled won't be loaded - * either, so disabling $name under it is harmless and must not be blocked. - * Only enabled dependents would be broken (ModuleLoader skips them on the next - * boot), so only those count. - * - * @return string[] - */ - private function enabledDependentsOf(string $name): array { - $out = []; - foreach ($this->listModules() as $module) { - if (($module['installed_version'] ?? '') === '') { - continue; // not installed — its requirements don't apply - } - $state = $module['state'] ?? null; - if (!($state instanceof ModuleState) || !$state->isLoadable()) { - continue; // already disabled/failed — disabling its dep won't break it - } - if (in_array($name, $module['dependencies'] ?? [], true)) { - $out[] = $module['name']; - } - } - return $out; - } - - /** - * Install any on-disk module that has never been installed. - * - * Bundled modules (e.g. ministra) are booted on every request but only get - * their install() / migrations run when explicitly installed. On a fresh - * panel nothing would create their tables, so this runs once during the - * migrate step (see StatusCommand) to provision them. It is idempotent: - * already-installed modules are skipped, and a module's up-migrations use - * CREATE TABLE IF NOT EXISTS so re-provisioning an existing panel is safe. - * - * Modules are installed in dependency order. Only admin-disabled modules are - * left untouched — a module whose previous install FAILED (or crashed mid-way, - * leaving it Installing) is retried here, so re-running `console.php status` - * self-heals once the root cause is fixed. Retrying is safe: the master schema - * uses CREATE TABLE IF NOT EXISTS. - * - * @return string[] Names of modules that were installed this pass. - */ - public function syncBundledModules(): array { - // Retire any legacy hash-less {name} directory before anything scans the - // modules folder, so every module ends up on the {name}_{hash5} scheme. - $this->migrateLegacyModuleDirs(); - - // Drop leftovers of modules whose functionality moved into the core - // (e.g. tmdb): a stale on-disk copy would still boot and its commands - // would collide with the core-registered ones. - $this->purgeCoreProvidedModules(); - - // Fetch any standard-set module that lives in a remote source (git/url/ - // platform) and isn't on disk yet — a no-op while every standard module is - // bundled on-disk. Fetched modules are installed by provisionStandardSet(), - // so the on-disk pass below simply skips them. - $provisioned = $this->provisionStandardSet(); - - $modules = $this->listModules(); - $installed = []; - foreach ($modules as $module) { - if (($module['installed_version'] ?? '') !== '') { - $installed[$module['name']] = true; - } - } - - // Candidates: present on disk, not yet installed, not admin-disabled. - // Failed/Installing (a never-completed install) are retried, not skipped. - $pending = []; - foreach ($modules as $module) { - $name = $module['name']; - if (isset($installed[$name])) { - continue; - } - if (($module['state'] ?? null) === ModuleState::Disabled) { - continue; - } - $pending[$name] = $module; - } - - // Install in dependency order: a module installs once all its declared - // dependencies are themselves installed. - $done = []; - $guard = 0; - while (!empty($pending) && $guard++ < 1000) { - $progressed = false; - foreach (array_keys($pending) as $name) { - $ready = true; - foreach ($pending[$name]['dependencies'] ?? [] as $dep) { - if (!isset($installed[$dep])) { - $ready = false; - break; - } - } - if (!$ready) { - continue; - } - try { - $this->installModule($name); - $installed[$name] = true; - $done[] = $name; - } catch (\Throwable $e) { - error_log("syncBundledModules: install '{$name}' failed: " . $e->getMessage()); - } - unset($pending[$name]); - $progressed = true; - } - if (!$progressed) { - break; // unmet or circular dependencies — stop - } - } - - return array_values(array_unique(array_merge($provisioned, $done))); - } - - /** - * The standard module set the panel provisions by default (config/bundled_modules.php). - * - * Foundation for modules-in-separate-repos: each entry is keyed by the module's - * permanent `hash_id`. When the config file is absent, falls back to whatever - * is on disk (treated as `bundled`). - * - * @return array - */ - public function getStandardSet(): array { - $path = defined('CONFIG_PATH') - ? CONFIG_PATH . 'bundled_modules.php' - : dirname(__DIR__, 2) . '/config/bundled_modules.php'; - - if (is_file($path)) { - $data = require $path; - if (is_array($data)) { - return array_values(array_filter($data, 'is_array')); - } - } - - // Fallback: derive from on-disk modules (all treated as bundled). - $out = []; - foreach ($this->listModules() as $m) { - $out[] = ['hash_id' => (string) ($m['hash_id'] ?? ''), 'name' => $m['name'], 'source' => 'bundled']; - } - return $out; - } - - /** - * Resolve the on-disk module name that carries a given permanent `hash_id`. - * - * The stable identity lookup: lets the panel recognise "the same module" across - * a rename or a repo move, where `name` alone is unreliable. - * - * @param string $hashId Permanent module hash_id. - * @return string|null Module name, or null if no on-disk module has that hash_id. - */ - public function findModuleByHashId(string $hashId): ?string { - $hashId = trim($hashId); - if ($hashId === '') { - return null; - } - foreach ($this->listModules() as $m) { - if (($m['hash_id'] ?? '') === $hashId) { - return $m['name']; - } - } - return null; - } - - /** - * Provision the standard set: fetch+install any standard-set module that lives - * in a remote source (git/url/platform) and is not on disk yet. - * - * `bundled` entries and modules already present (matched by `hash_id`) are left - * to syncBundledModules()'s on-disk install. Per-entry failures are logged, not - * fatal. No-op today (every standard module is bundled on-disk). - * - * @return string[] Names/hash_ids of modules fetched this pass. - */ - public function provisionStandardSet(): array { - $done = []; - foreach ($this->getStandardSet() as $entry) { - $hash = (string) ($entry['hash_id'] ?? ''); - $name = (string) ($entry['name'] ?? ''); - - // Already on disk (bundled or previously fetched)? Nothing to fetch. - // A same-name directory whose identity does NOT match the pinned - // hash_id is a stale pre-pin copy (e.g. a legacy-migrated bundled - // module from an older release) — it must be replaced, not kept: - // otherwise the panel runs outdated module code forever. - $onDisk = $hash !== '' ? $this->findModuleByHashId($hash) : null; - $staleDir = null; - if ($onDisk === null && $name !== '') { - $dir = $this->modulePathFor($name); - if (is_dir($dir) && is_file($dir . '/module.json')) { - if ($hash === '') { - $onDisk = $name; // no pin — any same-name copy counts - } else { - $staleDir = $dir; - } - } - } - if ($onDisk !== null) { - continue; - } - - $source = (string) ($entry['source'] ?? 'bundled'); - if ($source === 'bundled') { - error_log("provisionStandardSet: '{$name}' is declared bundled but missing on disk — skipped."); - continue; - } - - try { - if ($staleDir !== null) { - error_log("provisionStandardSet: '{$name}' on disk (" . basename($staleDir) . ") does not match the pinned hash_id — replacing with the pinned release."); - $this->deleteDirectory($staleDir); - } - $this->installModuleFromSource($entry); - $done[] = $name !== '' ? $name : $hash; - } catch (\Throwable $e) { - error_log("provisionStandardSet: fetch of '" . ($name !== '' ? $name : $hash) . "' failed: " . $e->getMessage()); - } - } - return $done; - } - - /** - * Fetch a NOT-yet-present module from a standard-set entry's source and install it. - * - * Mirrors updateModuleFromSource() but for a first install (no local module.json - * to read the source from — it comes from the entry). Verifies the fetched - * `hash_id` against the entry so a repo/URL cannot supply a different module. - * - * @param array $entry A getStandardSet() entry (source/repository/…, hash_id). - * @return void - * @throws \RuntimeException on download/verify/install failure. - */ - private function installModuleFromSource(array $entry): void { - $update = [ - 'source' => (string) ($entry['source'] ?? 'bundled'), - 'repository' => (string) ($entry['repository'] ?? ''), - 'channel' => (string) ($entry['channel'] ?? 'stable'), - 'slug' => (string) ($entry['slug'] ?? ($entry['name'] ?? '')), - 'url' => (string) ($entry['url'] ?? ''), - ]; - $expectedHash = (string) ($entry['hash_id'] ?? ''); - - // platform — the store install flow handles download/decrypt/license/LB. - if ($update['source'] === 'platform') { - $apiKey = (string) (SettingsManager::get('platform_api_key') ?? ''); - $slug = $update['slug'] !== '' ? $update['slug'] : (string) ($entry['name'] ?? ''); - $this->downloadFromPlatform($slug, '', $apiKey); - return; - } - - $version = (new ModuleUpdateChecker())->latestAvailable([ - 'update' => $update, - 'version' => '', - 'installed_version' => '', - ]); - if ($version === null) { - throw new \RuntimeException('No installable version resolved from source.'); - } - - [$url, $md5] = $this->resolveSourceDownload($update, $version); - if ($url === '') { - throw new \RuntimeException('No download URL resolved from source.'); - } - - $archive = (string) @tempnam(sys_get_temp_dir(), 'xc_modinst_'); - $tempBase = rtrim(sys_get_temp_dir(), '/') . '/xc_modinst_' . bin2hex(random_bytes(8)); - try { - $this->downloadToFile($url, $archive); - if ($md5 !== '' && !hash_equals(strtolower($md5), (string) md5_file($archive))) { - throw new \RuntimeException('Checksum mismatch on the downloaded module archive.'); - } - - $this->extractArchive($archive, $tempBase); - $moduleDir = $this->resolveExtractedModuleDir($tempBase); - - $meta = json_decode((string) @file_get_contents($moduleDir . '/module.json'), true); - $gotHash = is_array($meta) ? (string) ($meta['hash_id'] ?? '') : ''; - if ($expectedHash !== '' && $gotHash !== '' && !hash_equals($expectedHash, $gotHash)) { - throw new \RuntimeException('hash_id mismatch — fetched module is not the expected one.'); - } - - $name = $this->placeModuleFiles($moduleDir); - $manifest = $this->readModuleManifest($name); - $ver = (string) ($manifest['version'] ?? $version); - - $this->storeModuleArchive($archive, $name, $ver); - if ((string) ($this->readOverrides()[$name]['installed_version'] ?? '') !== '') { - // Files were re-provisioned for an already-installed module (a - // stale copy was replaced) — catch the schema up incrementally - // instead of re-running the initial install migrations. - $this->updateModule($name); - } else { - $this->installModule($name, $ver); - } - $this->recordModuleSource($name, 'local'); - $this->distributeToLoadBalancers($name, $manifest, 'local', $ver); - } finally { - @unlink($archive); - $this->deleteDirectory($tempBase); - } - } - - /** - * List all installed modules with their metadata and status. - * - * Scans the modules directory for module.json files, merges with - * config/modules.php overrides, and returns sorted results. - * - * @return array Module list. - */ - public function listModules(): array { - $overrides = $this->readOverrides(); - $items = []; - - $jsonFiles = glob($this->modulesPath . '/*/module.json') ?: []; - - // Pre-resolve each module's state by name so the dependency diagnostics - // below can see the full set while building items. - $stateByName = []; - foreach ($jsonFiles as $jsonFile) { - $meta = json_decode((string) @file_get_contents($jsonFile), true) ?: []; - // Key by the CANONICAL manifest name, not the `{name}_{hash5}` directory — - // config/modules.php and `dependencies` both reference the logical name. - $depName = (string) ($meta['name'] ?? basename(dirname($jsonFile))); - $stateByName[$depName] = ModuleState::fromRaw( - $overrides[$depName]['state'] ?? ($overrides[$depName]['enabled'] ?? null) - ); - } - - foreach ($jsonFiles as $jsonFile) { - $meta = json_decode((string) @file_get_contents($jsonFile), true) ?: []; - $name = (string) ($meta['name'] ?? basename(dirname($jsonFile))); // canonical name - $state = $stateByName[$name] ?? ModuleState::fromRaw(null); - - $dependencies = ModuleLoader::filterCoreProvidedDependencies( - is_array($meta['dependencies'] ?? null) ? $meta['dependencies'] : [] - ); - - // Flag a module that is nominally Enabled but won't actually load: - // ModuleLoader skips it when a required dependency is missing or not - // loadable (e.g. plex is Enabled but watch is Failed/Disabled). Mirrors - // ModuleLoader::pruneUnsatisfiableModules(). - $dependencyWarnings = []; - foreach ($dependencies as $dep) { - if (!isset($stateByName[$dep])) { - $dependencyWarnings[] = "Required dependency '{$dep}' is missing."; - } elseif (!$stateByName[$dep]->isLoadable()) { - $dependencyWarnings[] = "Required dependency '{$dep}' is not enabled (" - . $stateByName[$dep]->value . ').'; - } - } - - $items[] = [ - 'name' => $name, - 'hash_id' => (string) ($meta['hash_id'] ?? ''), - 'update' => ModuleLoader::normalizeUpdateBlock($meta, $name), - 'description' => $meta['description'] ?? '', - 'version' => $meta['version'] ?? '', - 'requires_core' => $meta['requires_core'] ?? '', - 'environment' => $meta['environment'] ?? 'main', - 'priority' => (int) ($meta['priority'] ?? 0), - 'dependencies' => $dependencies, - 'optional_dependencies' => is_array($meta['optional_dependencies'] ?? null) ? $meta['optional_dependencies'] : [], - 'has_navbar' => (bool) ($meta['has_navbar'] ?? false), - 'has_settings' => (bool) ($meta['has_settings'] ?? false), - 'enabled' => $state->isLoadable(), - 'state' => $state, - 'path' => dirname($jsonFile), - 'installed_version' => $overrides[$name]['installed_version'] ?? '', - 'available_version' => $overrides[$name]['available_version'] ?? '', - 'source' => $overrides[$name]['source'] ?? '', - 'previous_version' => $overrides[$name]['previous_version'] ?? '', - 'dependency_warnings' => $dependencyWarnings, - ]; - } - - usort($items, function ($a, $b) { - return strcmp($a['name'], $b['name']); - }); - - return $items; - } - - /** - * Install a module by name. - * - * Loads the module instance, runs install(), and enables it. - * - * @param string $name Module name (lowercase, alphanumeric + hyphens). - * @return void - * @throws \RuntimeException If the module cannot be loaded. - */ - public function installModule(string $name, ?string $version = null): void { - $name = $this->sanitizeModuleName($name); - $module = $this->loadModuleInstance($name); - - // Version priority: explicit $version (platform installs pass the - // authoritative SaaS release version) → module.json manifest → the - // module's hardcoded getVersion() (which can drift from the manifest). - $targetVersion = $version ?? $this->manifestVersion($name) ?? $module->getVersion(); - $modulePath = $this->modulePathFor($name); - - $this->setState($name, ModuleState::Installing); - - try { - $db = $this->getDb() ?? DatabaseFactory::get(); - // Apply the module's master schema, then its own install() hook for any - // non-SQL setup. NB: schema files are DDL (CREATE/ALTER), which - // MySQL/MariaDB implicitly commit — a wrapping transaction gives no - // rollback safety, and its rollback() on error throws "no active - // transaction", masking the real SQL error. So run directly and let the - // genuine failure propagate to the catch below (and into the logs). - if ($db !== null) { - // Fresh install applies the module's master schema (database.sql). - ModuleMigrator::install($modulePath, $db, (string) $targetVersion); - } - $module->install(); - } catch (\Throwable $e) { - $this->setState($name, ModuleState::Failed); - throw $e; - } - - $this->setState($name, ModuleState::Enabled); - $this->recordInstalledVersion($name, $targetVersion); - } - - /** - * Uninstall a module by name. - * - * Runs uninstall() and disables the module. - * - * @param string $name Module name. - * @return void - * @throws \RuntimeException If the module cannot be loaded. - */ - public function uninstallModule(string $name): void { - $name = $this->sanitizeModuleName($name); - - // Refuse to remove a module that still-installed dependents rely on - // (e.g. plex depends on watch — watch cannot be removed under it). - $dependents = $this->installedDependentsOf($name); - if (!empty($dependents)) { - throw new \RuntimeException( - "Cannot uninstall '{$name}': still required by " . implode(', ', $dependents) - . '. Uninstall ' . (count($dependents) === 1 ? 'it' : 'them') . ' first.' - ); - } - - $module = $this->loadModuleInstance($name); - - // The module's own uninstall() hook runs first (it clears the data/rows - // it created), then the module's schema is torn down via its single - // teardown file (database_drop.sql). - $module->uninstall(); - $db = $this->getDb() ?? DatabaseFactory::get(); - if ($db !== null) { - ModuleMigrator::uninstall($this->modulePathFor($name), $db); - } - - $this->clearInstalledVersion($name); - $this->setState($name, ModuleState::Disabled); - } - - /** - * Fully delete a module: uninstall it, then remove its directory from disk and - * its override entry from config/modules.php. - * - * Unlike uninstallModule() — which drops the tables and disables the module but - * leaves its files on disk so it stays listed and re-installable — this removes - * the module entirely. For a BUNDLED module (shipped in the deploy) the files - * come back on the next panel update; deletion is still honoured until then. - * - * Order matters: uninstall runs FIRST (drop tables + uninstall() hook) while the - * files are still on disk (the teardown SQL and the module class live there); - * only after a clean uninstall are the files removed, so no orphaned tables are - * left behind. A failing uninstall aborts the delete. The dependents guard is - * enforced up front. - * - * @param string $name Module name. - * @return void - * @throws \RuntimeException If an installed dependent still requires the module. - */ - public function deleteModule(string $name): void { - $name = $this->sanitizeModuleName($name); - - // Same guard as uninstall: refuse while an installed dependent needs it. - $dependents = $this->installedDependentsOf($name); - if (!empty($dependents)) { - throw new \RuntimeException( - "Cannot delete '{$name}': still required by " . implode(', ', $dependents) - . '. Delete ' . (count($dependents) === 1 ? 'it' : 'them') . ' first.' - ); - } - - // Read the manifest (for LB propagation) BEFORE the files are removed. - $manifest = []; - try { - $manifest = $this->readModuleManifest($name); - } catch (\Throwable $e) { - // No readable manifest — LB propagation just skips below. - } - - // Step 1 — uninstall FIRST, while the module's files are still on disk: - // run its uninstall() hook and drop its tables (teardown SQL + module class - // both live in the directory). A failure aborts the delete and propagates, - // so the files are never removed with orphaned tables left behind. - $this->uninstallModule($name); - - // Step 2 — remove the files, stored archives and the config override. - $this->deleteModuleFilesOnly($name); - - // Step 3 — propagate the deletion to every LB node that received this module. - $this->distributeDeletionToLoadBalancers($name, $manifest); - } - - /** - * Remove a module's files WITHOUT touching the database. - * - * Used on LB nodes (which share MAIN's database — dropping tables there would - * delete MAIN's data) and as the file-removal step of deleteModule(). Removes - * the module directory, its stored archives, and its config/modules.php entry. - * - * @param string $name Module name. - * @return void - */ - public function deleteModuleFilesOnly(string $name): void { - $name = $this->sanitizeModuleName($name); - - $modulePath = $this->modulePathFor($name); - if (is_dir($modulePath)) { - $this->deleteDirectory($modulePath); - } - - // Remove any stored marketplace archives (name_.zip). - foreach (glob($this->archivesPath . '/' . $name . '_*.zip') ?: [] as $rArchive) { - @unlink($rArchive); - } - - // Drop the config/modules.php override entry entirely. - $overrides = $this->readOverrides(); - if (isset($overrides[$name])) { - unset($overrides[$name]); - $this->writeOverrides($overrides); - } - } - - /** - * Tell every load balancer that received this module to delete it too. - * - * Mirrors distributeToLoadBalancers(): only MAIN dispatches, and only for - * modules that were distributed (environment lb/any). LB nodes act on the - * `delete_module` signal via RootSignalsCronJob → `console.php module:delete` - * (files-only — never a DB drop on the shared MAIN database). - * - * @param string $name Module name. - * @param array $manifest The module's manifest (read before deletion). - * @return void - */ - private function distributeDeletionToLoadBalancers(string $name, array $manifest): void { - // Cheap manifest check first — a MAIN-only module was never on any LB, so - // return before touching config (isLoadBalancer() reads via the extension). - $environment = strtolower((string) ($manifest['environment'] ?? 'main')); - if (!in_array($environment, ['lb', 'any'], true)) { - return; // MAIN-only — LB never had it - } - if ($this->isLoadBalancer()) { - return; - } - $db = $this->getDb(); - if ($db === null) { - return; - } - - $payload = json_encode(['action' => 'delete_module', 'name' => $name]); - - $db->query('SELECT `id` FROM `servers` WHERE `server_type` = 0 AND `is_main` = 0 AND `enabled` = 1;'); - $rServerIDs = array(); - foreach ($db->get_rows() as $rRow) { - $rServerIDs[] = intval($rRow['id']); - } - foreach ($rServerIDs as $rServerID) { - $db->query( - 'INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', - $rServerID, - time(), - $payload - ); - } - } - - /** - * Update a module, running only the incremental migrations needed. - * - * Reads the recorded installed_version from config/modules.php. - * If no version is recorded (legacy install), falls back to full installModule(). - * If already at the current version, does nothing. - * Otherwise runs all getMigrations() entries with version > installedVersion - * and version <= module->getVersion(), in ascending semver order. - * - * @param string $name Module name. - * @return void - */ - public function updateModule(string $name): void { - $name = $this->sanitizeModuleName($name); - $overrides = $this->readOverrides(); - $fromVersion = $overrides[$name]['installed_version'] ?? null; - - if ($fromVersion === null) { - $this->installModule($name); - return; - } - - $module = $this->loadModuleInstance($name); - $toVersion = $this->manifestVersion($name) ?? $module->getVersion(); - - if (version_compare($fromVersion, $toVersion, '>=')) { - return; - } - - // File-based schema migrations: every up file in (fromVersion, toVersion]. - $db = $this->getDb() ?? DatabaseFactory::get(); - if ($db !== null) { - ModuleMigrator::up($this->modulePathFor($name), $db, $fromVersion, (string) $toVersion); - } - - // Programmatic migrations (callables) — coexist with the file-based ones. - if ($module instanceof MigratableInterface) { - $this->runPendingMigrations($module->getMigrations(), $fromVersion, $toVersion); - } - - $this->recordInstalledVersion($name, $toVersion); - } - - /** - * Update a module by fetching new files from its declared source, then running - * migrations. This is what the panel's "Update" button triggers (P4). - * - * - bundled : files already ship with the panel → migrations only (updateModule()). - * - platform : delegated to the store flow (downloadFromPlatform — backup/restore/LB inside). - * - git/url : download archive → verify `hash_id` → backup → replace files → migrate → - * restore on failure → distribute to LB. - * - * Identity pinning: for git/url the fetched module.json `hash_id` must equal the - * installed one — a repo/URL cannot impersonate another module or hijack a rename. - * - * @param string $name Module name. - * @return string|null Version now on disk after the update, or null when the - * source had nothing newer (stale available_version cleared). - * @throws \RuntimeException on download/verify/apply failure (files rolled back). - */ - public function updateModuleFromSource(string $name): ?string { - $name = $this->sanitizeModuleName($name); - $manifest = $this->readModuleManifest($name); - $overrides = $this->readOverrides(); - - $update = ModuleLoader::normalizeUpdateBlock($manifest, $name); - $installed = (string) ($overrides[$name]['installed_version'] ?? ''); - $source = $update['source']; - - // bundled — the panel already replaced the files; just catch the schema up. - if ($source === 'bundled') { - $this->updateModule($name); - $this->recordAvailableVersion($name, null); - return $this->manifestVersion($name); - } - - // platform — reuse the full store flow (self-contained rollback + LB fan-out). - if ($source === 'platform') { - $apiKey = (string) (SettingsManager::get('platform_api_key') ?? ''); - $this->downloadFromPlatform(($update['slug'] !== '' ? $update['slug'] : $name), '', $apiKey); - $this->recordAvailableVersion($name, null); - return $this->manifestVersion($name); - } - - // git / url — fetch, verify, apply. - $checker = new ModuleUpdateChecker(); - $version = $checker->latestAvailable([ - 'update' => $update, - 'version' => (string) ($manifest['version'] ?? ''), - 'installed_version' => $installed, - ]); - if ($version === null && $checker->lastError() !== null) { - // Source unreachable (e.g. GitHub API rate limit) — fail loudly instead - // of silently doing nothing while the caller reports "updated". - throw new \RuntimeException( - "Cannot resolve the latest version of '{$name}' from its {$source} source: " . $checker->lastError() - ); - } - if ($version === null || ($installed !== '' && version_compare($version, $installed, '<='))) { - // Nothing newer at the source — the recorded available_version is stale - // (already updated, or the release was pulled). Clear it so the Update - // button disappears instead of reappearing forever. - $this->recordAvailableVersion($name, null); - return null; - } - - [$downloadUrl, $expectedMd5] = $this->resolveSourceDownload($update, $version); - if ($downloadUrl === '') { - throw new \RuntimeException("No download URL resolved for module '{$name}' (source '{$source}')."); - } - - $archive = (string) @tempnam(sys_get_temp_dir(), 'xc_modupd_'); - if ($archive === '') { - throw new \RuntimeException('Unable to create a temp file for the module download.'); - } - $tempBase = rtrim(sys_get_temp_dir(), '/') . '/xc_modupd_' . bin2hex(random_bytes(8)); - - try { - $this->downloadToFile($downloadUrl, $archive); - if ($expectedMd5 !== '' && !hash_equals(strtolower($expectedMd5), (string) md5_file($archive))) { - throw new \RuntimeException('Checksum mismatch on the downloaded module archive.'); - } - - $this->extractArchive($archive, $tempBase); - $moduleDir = $this->resolveExtractedModuleDir($tempBase); - - // Identity pinning — the fetched module must be the SAME module. - $newMeta = json_decode((string) @file_get_contents($moduleDir . '/module.json'), true); - $newHash = is_array($newMeta) ? (string) ($newMeta['hash_id'] ?? '') : ''; - $ownHash = (string) ($manifest['hash_id'] ?? ''); - if ($ownHash !== '' && $newHash !== '' && !hash_equals($ownHash, $newHash)) { - throw new \RuntimeException("hash_id mismatch — refusing to overwrite '{$name}' with a different module."); - } - - $targetDir = $this->modulePathFor($name); - $backupDir = $this->backupModuleDir($name, $targetDir); - try { - $this->copyDirectory($moduleDir, $targetDir); - $this->updateModule($name); // incremental migrations to the new manifest version - - $fresh = $this->readModuleManifest($name); - $resolvedVer = (string) ($fresh['version'] ?? $version); - $this->recordAvailableVersion($name, null); - - // Keep a local archive so LB nodes can pull it (getFile), then fan out. - $this->storeModuleArchive($archive, $name, $resolvedVer); - $this->distributeToLoadBalancers($name, $fresh, 'local', $resolvedVer); - - if ($backupDir !== null) { - $this->deleteDirectory($backupDir); - } - - return $resolvedVer; - } catch (\Throwable $e) { - $this->restoreModuleBackup($name, $targetDir, $backupDir, $installed !== '' ? $installed : null); - throw new \RuntimeException("Update of '{$name}' failed — rolled back: " . $e->getMessage(), 0, $e); - } - } finally { - @unlink($archive); - $this->deleteDirectory($tempBase); - } - } - - /** - * Resolve the download URL (+ optional expected md5) for a git/url source. - * - * git : release asset `module.tar.gz` at the tag == $version; md5 (if any) - * comes from the release's `hashes.md5` via GitHubReleases::getAssetHash(). - * url : re-fetch the `version.json` for its `download` (https) and optional `md5`. - * - * @return array{0:string,1:string} [downloadUrl, expectedMd5] — url '' if unresolved. - */ - private function resolveSourceDownload(array $update, string $version): array { - if (($update['source'] ?? '') === 'git') { - if (!preg_match('~github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$~i', (string) ($update['repository'] ?? ''), $m)) { - return ['', '']; - } - $asset = 'module.tar.gz'; // convention: module repo release ships this asset - $url = "https://github.com/{$m[1]}/{$m[2]}/releases/download/{$version}/{$asset}"; - $md5 = ''; - try { - $channel = in_array((string) ($update['channel'] ?? 'stable'), ['beta', 'unstable'], true) ? 'beta' : 'stable'; - $md5 = (string) ((new GitHubReleases($m[1], $m[2], $channel))->getAssetHash($version, $asset) ?? ''); - } catch (\Throwable $e) { - // no hash available → download proceeds unverified - } - return [$url, $md5]; - } - - if (($update['source'] ?? '') === 'url') { - $data = json_decode($this->httpGetString((string) ($update['url'] ?? '')), true); - $dl = is_array($data) ? trim((string) ($data['download'] ?? '')) : ''; - $md5 = is_array($data) ? trim((string) ($data['md5'] ?? '')) : ''; - if (stripos($dl, 'https://') !== 0) { - $dl = ''; - } - return [$dl, $md5]; - } - - return ['', '']; - } - - /** cURL GET a small https resource to a string ('' on failure/non-https). */ - private function httpGetString(string $url): string { - if (stripos($url, 'https://') !== 0) { - return ''; - } - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT => 15, - CURLOPT_TIMEOUT => 30, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_USERAGENT => 'XC_VM-ModuleManager', - ]); - $body = curl_exec($ch); - curl_close($ch); - return is_string($body) ? $body : ''; - } - - /** Download an https URL straight to $dest; throws on non-https / HTTP error. */ - private function downloadToFile(string $url, string $dest): void { - // Shared streaming primitive (see CurlClient::downloadToFile); kept as a - // thin wrapper so existing call sites and error text stay stable. - CurlClient::downloadToFile($url, $dest); - } - - /** - * Set the lifecycle state of a module in config/modules.php. - * - * When state is Enabled the 'state' key is removed entirely (clean default). - * When state is anything else the string value is persisted as 'state'. - * - * @param string $name Module name. - * @param ModuleState $state Target lifecycle state. - * @return void - */ - public function setState(string $name, ModuleState $state): void { - $name = $this->sanitizeModuleName($name); - - // Refuse to disable a module that still-enabled dependents rely on - // (e.g. plex requires watch — watch cannot be disabled under it, or - // ModuleLoader would skip plex on the next boot). Mirrors the guard in - // uninstallModule(). Scoped strictly to a deliberate Disabled transition: - // the internal lifecycle states (Installing, Failed) are also non-loadable - // but are set by installModule() itself and must never be blocked. - if ($state === ModuleState::Disabled) { - $dependents = $this->enabledDependentsOf($name); - if (!empty($dependents)) { - throw new \RuntimeException( - "Cannot disable '{$name}': still required by " . implode(', ', $dependents) - . '. Disable ' . (count($dependents) === 1 ? 'it' : 'them') . ' first.' - ); - } - } - - $overrides = $this->readOverrides(); - - if (!isset($overrides[$name]) || !is_array($overrides[$name])) { - $overrides[$name] = []; - } - - // Remove any legacy bool 'enabled' key — we use 'state' now. - unset($overrides[$name]['enabled']); - - if ($state === ModuleState::Enabled) { - // Enabled is the default: clean up the key so the file stays minimal. - unset($overrides[$name]['state']); - if (empty($overrides[$name])) { - unset($overrides[$name]); - } - } else { - $overrides[$name]['state'] = $state->value; - } - - $this->writeOverrides($overrides); - } - - /** - * Enable or disable a module in config/modules.php. - * - * @deprecated Use setState(name, ModuleState::Enabled / ModuleState::Disabled) instead. - * - * @param string $name Module name. - * @param bool $enabled True to enable, false to disable. - * @return void - */ - public function setEnabled(string $name, bool $enabled): void { - $this->setState($name, $enabled ? ModuleState::Enabled : ModuleState::Disabled); - } - - /** - * Upload a zip archive and install the module from it. - * - * Extracts the archive to a temp directory, validates structure, - * copies to the modules path, and runs installModule(). - * - * @param string $zipFilePath Path to the uploaded zip file. - * @return string Installed module name. - * @throws \RuntimeException If extraction or installation fails. - * @throws \InvalidArgumentException If the zip file is not found. - */ - public function uploadAndInstall(string $zipFilePath): string { - if (!is_file($zipFilePath)) { - throw new \InvalidArgumentException('Uploaded zip file not found.'); - } - - $tempBase = rtrim(sys_get_temp_dir(), '/') . '/xc_module_' . bin2hex(random_bytes(8)); - if (!@mkdir($tempBase, 0755, true) && !is_dir($tempBase)) { - throw new \RuntimeException('Unable to create temporary directory.'); - } - - try { - $this->extractArchive($zipFilePath, $tempBase); - - $moduleDir = $this->resolveExtractedModuleDir($tempBase); - $moduleName = $this->placeModuleFiles($moduleDir); - - $this->installModule($moduleName); - - // Keep a copy of the uploaded archive so it can be redistributed to - // LB servers (which have no access to the store for custom modules). - $manifest = $this->readModuleManifest($moduleName); - $version = (string) ($manifest['version'] ?? '0.0.0'); - $this->storeModuleArchive($zipFilePath, $moduleName, $version); - - // Custom (non-store) module — no store rollback available. - $this->recordModuleSource($moduleName, 'local'); - - // If the manifest targets load balancers, push the archive to every LB. - $this->distributeToLoadBalancers($moduleName, $manifest, 'local', $version); - - return $moduleName; - } finally { - $this->deleteDirectory($tempBase); - } - } - - /** - * LB-side: install a custom (non-store) module from a local archive, - * deploying its FILES ONLY — no DB migrations (the shared DB was already - * migrated by MAIN). - * - * @param string $zipFilePath Path to the .zip archive fetched from MAIN. - * @return string Installed module name. - */ - public function deployFromArchiveFilesOnly(string $zipFilePath): string { - if (!is_file($zipFilePath)) { - throw new \InvalidArgumentException('Module archive not found.'); - } - - $tempBase = rtrim(sys_get_temp_dir(), '/') . '/xc_module_' . bin2hex(random_bytes(8)); - if (!@mkdir($tempBase, 0755, true) && !is_dir($tempBase)) { - throw new \RuntimeException('Unable to create temporary directory.'); - } - - try { - $this->extractArchive($zipFilePath, $tempBase); - - $moduleDir = $this->resolveExtractedModuleDir($tempBase); - $moduleName = $this->placeModuleFiles($moduleDir); - $targetDir = $this->modulePathFor($moduleName); - - // Keep the archive locally too, so this LB can re-seed if needed. - $manifest = $this->readModuleManifest($moduleName); - $version = (string) ($manifest['version'] ?? '0.0.0'); - $this->storeModuleArchive($zipFilePath, $moduleName, $version); - - $this->recordInstalledVersion($moduleName, $version); - $this->setState($moduleName, ModuleState::Enabled); - $this->hotReloadSafe($moduleName, $targetDir); - - return $moduleName; - } finally { - $this->deleteDirectory($tempBase); - } - } - - /** - * Copy a module archive into the local archives directory as - * {name}_{version}.zip (idempotent — overwrites any previous copy). - */ - private function storeModuleArchive(string $sourceZip, string $name, string $version): void { - $dest = $this->archivePathFor($name, $version); - $dir = dirname($dest); - if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { - throw new \RuntimeException('Unable to create modules archive directory.'); - } - if (realpath($sourceZip) !== realpath($dest) && !@copy($sourceZip, $dest)) { - throw new \RuntimeException('Unable to store module archive.'); - } - @chmod($dest, 0644); - } - - /** - * Read a module's manifest (module.json) from the modules directory. - * - * @return array Decoded manifest, or [] if missing/invalid. - */ - private function readModuleManifest(string $name): array { - $name = $this->sanitizeModuleName($name); - // Resolve the real {name}_{hash5} (or legacy bare) directory — reading the - // bare path would miss the manifest for a hash-suffixed module. - $file = $this->modulePathFor($name) . '/module.json'; - if (!is_file($file)) { - return []; - } - $data = json_decode((string) @file_get_contents($file), true); - return is_array($data) ? $data : []; - } - - /** - * The module's version as declared in its module.json, or null if absent. - * Authoritative source for the installed version (the module's getVersion() - * is hardcoded and may drift from the shipped manifest). - * - * @param string $name Module name / directory. - * @return string|null - */ - private function manifestVersion(string $name): ?string { - $v = $this->readModuleManifest($name)['version'] ?? null; - return (is_string($v) && $v !== '') ? $v : null; - } - - /** - * Tell every enabled load balancer to install a module, if the manifest - * targets the LB environment. Runs only on MAIN. - * - * Inserts one `signals` row per LB with custom_data: - * {"action":"install_module","source":"platform|local","name":"…","version":"…"} - * The LB's root signals daemon picks it up and runs `console.php module:install`. - * - * @param string $name Module name / slug. - * @param array $manifest Decoded module.json. - * @param string $source 'platform' (pull from store) or 'local' (pull archive from MAIN). - * @param string $version Module version. - */ - private function distributeToLoadBalancers(string $name, array $manifest, string $source, string $version): void { - // Only MAIN distributes. LB installs are file-only and never re-dispatch. - if ($this->isLoadBalancer()) { - return; - } - - $environment = strtolower((string) ($manifest['environment'] ?? 'main')); - if (!in_array($environment, ['lb', 'any'], true)) { - return; // module is MAIN-only — nothing to distribute - } - - $db = $this->getDb(); - if ($db === null) { - return; - } - - $payload = json_encode([ - 'action' => 'install_module', - 'source' => $source === 'platform' ? 'platform' : 'local', - 'name' => $name, - 'version' => $version, - ]); - - // LB servers are streaming servers (server_type = 0) that are not the - // main panel and are enabled. Collect ids first so the INSERT loop does - // not clobber the active result set. - $db->query('SELECT `id` FROM `servers` WHERE `server_type` = 0 AND `is_main` = 0 AND `enabled` = 1;'); - $rServerIDs = array(); - foreach ($db->get_rows() as $rRow) { - $rServerIDs[] = intval($rRow['id']); - } - - foreach ($rServerIDs as $rServerID) { - $db->query( - 'INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', - $rServerID, - time(), - $payload - ); - } - } - - /** - * @return bool True when running on a load balancer (is_lb = 1 in config). - */ - private function isLoadBalancer(): bool { - if (class_exists(ConfigReader::class)) { - return (bool) ConfigReader::get('is_lb'); - } - if (defined('SERVER_TYPE')) { - // SERVER_TYPE is an external runtime constant (not define()d in-tree); - // use constant() so static analysis doesn't flag an undefined constant. - // Mirrors ModuleLoader::detectEnvironment(). - return constant('SERVER_TYPE') === 'lb'; - } - return false; - } - - /** - * Download a module from the SaaS platform and install it. - * - * Delegates the full download → key-unwrap → extract flow to the - * XC_VM C extension, then runs installModule() to register it. - * Fires PackageInstalledEvent and hot-reloads the module into the - * current ServiceContainer without requiring a PHP-FPM restart. - * - * @param string $slug Module slug as listed on the platform. - * @param string $version Exact version string (e.g. "1.2.0"), or '' for the latest. - * @param string|null $apiKey API key for the SaaS platform. - * @return void - * @throws \RuntimeException If the C extension is missing, download fails, or install fails. - */ - public function downloadFromPlatform(string $slug, string $version = '', ?string $apiKey = null): void { - $slug = $this->sanitizeModuleName($slug); - $targetDir = $this->modulesPath . '/' . $slug; - - // Snapshot current state so a failed (re)install rolls back cleanly. We - // MOVE the existing module aside (outside modulesPath so the loader never - // scans it) — this both gives a clean dir for the new extract and a - // restore point. installed_version is captured for the version record. - $prevVersion = $this->readOverrides()[$slug]['installed_version'] ?? null; - $backupDir = $this->backupModuleDir($slug, $targetDir); - - try { - $result = $this->pullFilesFromPlatform($slug, $version, $apiKey); - $modulePath = $result['path']; - $resolvedVersion = (string) ($result['version'] ?: $version); - - // Acquire the per-machine ionCube license BEFORE installModule(): if the - // platform encoded the module with --with-license, the loader rejects the - // encoded files at require-time unless a valid .lic is already present. - // No-op when the platform has licensing disabled. - $this->acquireModuleLicense($slug, $apiKey); - - // Record the version the PLATFORM served (authoritative for store - // installs); the module's module.json/getVersion() may lag behind. - $this->installModule($slug, $resolvedVersion); - - EventDispatcher::dispatch(new PackageInstalledEvent( - slug: $result['module'], - version: $resolvedVersion, - path: $modulePath, - installedAt: time(), - )); - - $this->hotReload($slug, $modulePath); - - // Mark as store-installed and remember the version the Rollback - // button should target. The previous version is authoritative from - // the PLATFORM (it holds release history); it may be null when the - // platform has no prior approved version. (NB: $prevVersion below is - // the LOCAL pre-install version, used only for failure rollback.) - $this->recordPlatformSource($slug, $result['previous_version'] ?? null); - - // If the manifest targets load balancers, tell every LB to pull the - // same module from the platform with its own install_id (MAIN-only). - $this->distributeToLoadBalancers($slug, $this->readModuleManifest($slug), 'platform', $resolvedVersion); - - // Success — drop the rollback snapshot. - if ($backupDir !== null) { - $this->deleteDirectory($backupDir); - } - } catch (\Throwable $e) { - $restored = $this->restoreModuleBackup($slug, $targetDir, $backupDir, $prevVersion); - throw new \RuntimeException( - "Platform install of '{$slug}' failed" - . ($restored - ? ' — rolled back to previous version' . ($prevVersion ? " {$prevVersion}" : '') - : '') - . ': ' . $e->getMessage(), - 0, - $e - ); - } - } - - /** - * Roll a store-installed module back to its previously installed version. - * - * Uses the `previous_version` recorded at the last store install (still - * available on the platform within its retention window). Re-installs that - * exact version, then clears `previous_version` (one-shot rollback). LBs are - * re-synced to the rolled-back version via the normal distribution path. - * - * @param string $slug Module slug. - * @param string|null $apiKey Platform API key. - * @throws \RuntimeException If the module was not installed from the store or - * has no recorded previous version. - */ - public function rollbackFromPlatform(string $slug, ?string $apiKey = null): void { - $slug = $this->sanitizeModuleName($slug); - $overrides = $this->readOverrides(); - $entry = $overrides[$slug] ?? []; - - if (($entry['source'] ?? '') !== 'platform') { - throw new \RuntimeException("Module '{$slug}' was not installed from the store; cannot roll back."); - } - $previous = $entry['previous_version'] ?? ''; - if ($previous === '') { - throw new \RuntimeException("No previous version recorded for '{$slug}'."); - } - - // Re-installs the previous version (explicit, not "latest"). - $this->downloadFromPlatform($slug, $previous, $apiKey); - - // One-shot: drop the previous-version marker so the rollback button hides - // until the next successful update creates a new restore point. - $this->clearPreviousVersion($slug); - } - - /** Mark a module as store-installed and (optionally) record the replaced version. */ - private function recordPlatformSource(string $name, ?string $previousVersion): void { - $overrides = $this->readOverrides(); - if (!isset($overrides[$name]) || !is_array($overrides[$name])) { - $overrides[$name] = []; - } - $overrides[$name]['source'] = 'platform'; - if ($previousVersion !== null && $previousVersion !== '') { - $overrides[$name]['previous_version'] = $previousVersion; - } - $this->writeOverrides($overrides); - } - - /** Record the install source for a module (e.g. 'platform' or 'local'). */ - private function recordModuleSource(string $name, string $source): void { - $overrides = $this->readOverrides(); - if (!isset($overrides[$name]) || !is_array($overrides[$name])) { - $overrides[$name] = []; - } - $overrides[$name]['source'] = $source; - $this->writeOverrides($overrides); - } - - /** - * Fetch and install the per-machine ionCube license for a module. - * - * Generates this machine's ionCube server-data, asks the platform to mint a - * hardware-bound + expiring .lic (XC_VM::module_license) and writes it next to - * the encoded files (the name the encoder's --with-license expects). - * - * Best-effort: when the platform has licensing disabled, the module is not - * entitled, or the loader/extension lacks the needed functions, it silently - * writes nothing — so unlicensed-encoded modules still install normally. - * - * @return bool True if a .lic was written. - */ - private function acquireModuleLicense(string $slug, ?string $apiKey): bool { - if (!class_exists('XC_VM') || !function_exists('ioncube_server_data')) { - return false; - } - - $serverData = @ioncube_server_data(); - if (!is_string($serverData) || $serverData === '') { - error_log("ModuleManager: license skipped for '{$slug}': ioncube_server_data() empty"); - return false; - } - - try { - $res = \XC_VM::module_license($slug, base64_encode($serverData), $apiKey ?? ''); - } catch (\Throwable $e) { - error_log("ModuleManager: license request failed for '{$slug}': " . $e->getMessage()); - return false; - } - - if (!is_array($res) || empty($res['ok'])) { - $reason = is_array($res) ? ($res['reason'] ?? 'unknown') : 'no_response'; - error_log("ModuleManager: license NOT issued for '{$slug}': {$reason}" - . (($apiKey ?? '') === '' ? ' (api_key пуст — для лицензии он обязателен)' : '')); - return false; - } - - $licName = basename((string) ($res['license_name'] ?? 'module.lic')); - $licBytes = base64_decode((string) ($res['license'] ?? ''), true); - if ($licName === '' || $licBytes === false || $licBytes === '') { - error_log("ModuleManager: license for '{$slug}' empty/undecodable in response"); - return false; - } - - $dir = $this->modulesPath . '/' . $slug; - if (!is_dir($dir)) { - error_log("ModuleManager: module dir missing for '{$slug}': {$dir}"); - return false; - } - - if (@file_put_contents($dir . '/' . $licName, $licBytes) === false) { - error_log("ModuleManager: failed to write license {$dir}/{$licName} (права?)"); - return false; - } - return true; - } - - /** - * Re-issue the per-machine license for an installed platform module — call - * before expiry, or after a "license expired/invalid" load failure. The SaaS - * refusing to mint (lapsed subscription / revoked) is the effective kill. - * - * @return bool True if a fresh .lic was written. - */ - public function renewModuleLicense(string $slug, ?string $apiKey = null): bool { - return $this->acquireModuleLicense($this->sanitizeModuleName($slug), $apiKey); - } - - /** Remove the recorded previous version for a module. */ - private function clearPreviousVersion(string $name): void { - $overrides = $this->readOverrides(); - if (isset($overrides[$name]['previous_version'])) { - unset($overrides[$name]['previous_version']); - $this->writeOverrides($overrides); - } - } - - /** - * Move an installed module directory to a backup location outside the - * modules path (so ModuleLoader never scans it). Returns the backup path, - * or null if the module was not installed. Falls back to copy+delete if the - * move (rename) fails. - */ - private function backupModuleDir(string $slug, string $targetDir): ?string { - if (!is_dir($targetDir)) { - return null; - } - $base = dirname($this->modulesPath) . '/.module_backups'; - if (!is_dir($base) && !@mkdir($base, 0755, true) && !is_dir($base)) { - // Cannot create a backup area — copy in place as a last resort is not - // possible; proceed without rollback rather than block the install. - return null; - } - $backupDir = $base . '/' . $slug . '_' . bin2hex(random_bytes(4)); - if (@rename($targetDir, $backupDir)) { - return $backupDir; - } - // rename failed (e.g. cross-device) — copy then remove the original. - $this->copyDirectory($targetDir, $backupDir); - $this->deleteDirectory($targetDir); - return $backupDir; - } - - /** - * Restore a module backup created by backupModuleDir() after a failed - * install, re-recording the previous installed version. Returns true if a - * backup was restored. - */ - private function restoreModuleBackup(string $slug, string $targetDir, ?string $backupDir, ?string $prevVersion): bool { - // Remove the (possibly partial) failed install first. - $realModules = realpath($this->modulesPath); - $realTarget = realpath($targetDir) ?: $targetDir; - if ($realModules && str_starts_with($realTarget, $realModules . '/')) { - $this->deleteDirectory($targetDir); - } - - if ($backupDir === null || !is_dir($backupDir)) { - return false; - } - - if (!@rename($backupDir, $targetDir)) { - $this->copyDirectory($backupDir, $targetDir); - $this->deleteDirectory($backupDir); - } - - if ($prevVersion !== null) { - $this->recordInstalledVersion($slug, $prevVersion); - } - $this->setState($slug, ModuleState::Enabled); - return true; - } - - /** - * LB-side: download a store module from the platform and deploy its FILES - * ONLY — no DB migrations. - * - * LB servers share MAIN's database, so the module's schema migrations have - * already been applied by MAIN. Here we only need the decrypted code on the - * LB so it loads under environment=lb/any. Each LB registers and downloads - * with its OWN install_id. - * - * @param string $slug Module slug on the platform. - * @param string $version Exact version string. - * @param string|null $apiKey Shared platform API key (from settings). - * @return void - */ - public function deployFromPlatformFilesOnly(string $slug, string $version, ?string $apiKey = null): void { - $result = $this->pullFilesFromPlatform($slug, $version, $apiKey); - - EventDispatcher::dispatch(new PackageInstalledEvent( - slug: $result['module'], - version: $result['version'], - path: $result['path'], - installedAt: time(), - )); - - $this->recordInstalledVersion($slug, (string) ($result['version'] ?: $version)); - $this->setState($slug, ModuleState::Enabled); - $this->hotReloadSafe($slug, $result['path']); - } - - /** - * Register the panel and pull (download + decrypt + extract) a module's - * files from the platform via the C extension. Does NOT run installModule(). - * - * @return array{ok: bool, module: string, version: string, path: string} - * @throws \RuntimeException On a missing extension, registration or download failure. - */ - private function pullFilesFromPlatform(string $slug, string $version, ?string $apiKey): array { - if (!class_exists('XC_VM')) { - throw new \RuntimeException('XC_VM extension is not loaded. Install xcvm_core.so and enable it in php.ini.'); - } - - // Ensure this panel is registered with the platform before installing. - // module_install() asks the SaaS to wrap the module key in an X25519 - // SealedBox for *this* panel's public key; if the panel was never - // registered the /plugins/key endpoint answers "panel_not_registered" - // and the install fails. Registration is an idempotent upsert keyed by - // install_id, so running it before every install also guarantees the - // server holds the public key matching our current local secret key. - $reg = \XC_VM::panel_register($apiKey ?? ''); - if (!is_array($reg) || empty($reg['ok'])) { - $regReason = $reg['message'] ?? ($reg['reason'] ?? 'unknown'); - throw new \RuntimeException("Panel registration with platform failed for module '{$slug}': {$regReason}"); - } - - $result = \XC_VM::module_install($slug, $version, $apiKey ?? ''); - - if (!is_array($result) || empty($result['ok'])) { - $reason = $result['error'] ?? 'unknown'; - throw new \RuntimeException("Platform download failed for module '{$slug}': {$reason}"); - } - - return [ - 'ok' => true, - 'module' => $result['module'] ?? $slug, - 'version' => $result['version'] ?? $version, - 'path' => $result['path'] ?? ($this->modulesPath . '/' . $slug), - // Previous approved version reported by the platform (for the - // Rollback button). May be absent/null when there is no prior version. - 'previous_version' => $result['previous_version'] ?? null, - ]; - } - - /** - * Hot-reload a newly installed module into the running ServiceContainer. - * - * Loads and boots the module within the current request so it becomes - * immediately usable without a PHP-FPM restart. - * - * @param string $slug Module name. - * @param string $modulePath Absolute path to the module directory. - */ - /** - * Hot-reload only when serving a web request, never crash the caller. - * - * On an LB the install runs from a root CLI cron where there is no live - * request to keep warm and no router/container wired — the module simply - * loads from disk on the next request. So skip hot-reload under CLI and - * swallow any error. - */ - private function hotReloadSafe(string $slug, string $modulePath): void { - if (PHP_SAPI === 'cli') { - return; - } - try { - $this->hotReload($slug, $modulePath); - } catch (\Throwable $e) { - error_log("ModuleManager: hot-reload skipped for '{$slug}': " . $e->getMessage()); - } - } - - /** - * Hot-reload a freshly installed module without restarting PHP-FPM. - * - * Loads the module, boots it and registers its routes against the live - * container so it becomes usable within the current request lifecycle. - * - * @param string $slug Module slug. - * @param string $modulePath Filesystem path to the module. - * @return void - */ - private function hotReload(string $slug, string $modulePath): void { - $container = ServiceContainer::getInstance(); - - $loader = new ModuleLoader(); - if (!$loader->load($slug, $modulePath)) { - return; - } - - $router = $container->getOrDefault('router'); - $loader->bootAll($container, $router instanceof Router ? $router : null); - } - - /** - * Run migrations whose target version falls in (fromVersion, toVersion]. - * - * @param array $migrations - * @param string $fromVersion Currently installed version (exclusive lower bound). - * @param string $toVersion New version (inclusive upper bound). - */ - private function runPendingMigrations(array $migrations, string $fromVersion, string $toVersion): void { - $pending = []; - foreach ($migrations as $version => $callable) { - if ( - version_compare($version, $fromVersion, '>') && - version_compare($version, $toVersion, '<=') - ) { - $pending[$version] = $callable; - } - } - - uksort($pending, 'version_compare'); - - $db = $this->getDb(); - foreach ($pending as $callable) { - if ($db !== null && method_exists($db, 'transactional')) { - $db->transactional(fn() => $callable($this->container)); - } else { - $callable($this->container); - } - } - } - - /** - * Persist the installed version for a module in config/modules.php. - * - * @param string $name Module name. - * @param string $version Installed version string. - */ - private function recordInstalledVersion(string $name, string $version): void { - $overrides = $this->readOverrides(); - if (!isset($overrides[$name]) || !is_array($overrides[$name])) { - $overrides[$name] = []; - } - $overrides[$name]['installed_version'] = $version; - $this->writeOverrides($overrides); - } - - /** - * Remove the recorded installed version for a module from config/modules.php. - * - * @param string $name Module name. - */ - private function clearInstalledVersion(string $name): void { - $overrides = $this->readOverrides(); - if (!isset($overrides[$name]['installed_version'])) { - return; - } - unset($overrides[$name]['installed_version']); - if (empty($overrides[$name])) { - unset($overrides[$name]); - } - $this->writeOverrides($overrides); - } - - /** - * Record (or clear) the latest available version for a module in - * config/modules.php — written by the update-availability check - * (ModuleUpdatesCronJob) and read back by listModules()/the UI to show the - * Update button only when a newer version actually exists at the source. - * - * A null/empty version clears the flag (nothing newer, or not checkable). - * - * @param string $name Module name. - * @param string|null $version Latest available version, or null to clear. - * @return void - */ - public function recordAvailableVersion(string $name, ?string $version): void { - $name = $this->sanitizeModuleName($name); - $version = $version !== null ? trim($version) : ''; - - $overrides = $this->readOverrides(); - $current = (string) ($overrides[$name]['available_version'] ?? ''); - if ($current === $version) { - return; // no change — avoid a needless config rewrite - } - - if ($version === '') { - unset($overrides[$name]['available_version']); - if (isset($overrides[$name]) && empty($overrides[$name])) { - unset($overrides[$name]); - } - } else { - $overrides[$name]['available_version'] = $version; - } - $this->writeOverrides($overrides); - } - - /** - * Load and return a module instance by name. - * - * @param string $name Module name. - * @return object Module instance implementing ModuleInterface. - * @throws \RuntimeException If the module cannot be loaded or instantiated. - */ - private function loadModuleInstance(string $name) { - $name = $this->sanitizeModuleName($name); - $loader = new ModuleLoader(); - // Resolve the real directory ({name}_{hash5}, or a legacy bare {name}) — the - // module rarely lives at the bare path, so passing that would fail to find - // the class file for a freshly-uploaded module and abort its install. - $ok = $loader->load($name, $this->modulePathFor($name)); - if (!$ok) { - throw new ModuleNotFoundException('Cannot load module: ' . $name); - } - - $module = $loader->getModule($name); - if (!$module) { - throw new ModuleNotFoundException('Module instance is not available: ' . $name); - } - - return $module; - } - - /** - * Validate and sanitize a module name. - * - * @param string $name Raw module name. - * @return string Sanitized module name. - * @throws \InvalidArgumentException If the name is invalid. - */ - private function sanitizeModuleName(string $name): string { - $name = trim((string) $name); - if (!preg_match('/^[a-z0-9][a-z0-9\-]*$/', $name)) { - throw new ModuleException('Invalid module name.'); - } - return $name; - } - - /** - * Read module overrides from config/modules.php. - * - * @return array Module overrides keyed by module name. - */ - private function readOverrides(): array { - if (!file_exists($this->overridesPath)) { - return []; - } - - $data = require $this->overridesPath; - return is_array($data) ? $data : []; - } - - /** - * Write module overrides to config/modules.php atomically. - * - * Writes to a sibling temp file then renames into place, so concurrent - * requests can never read a partially-written file. - * - * @param array $overrides Module overrides to persist. - * @return void - * @throws \RuntimeException If the file cannot be written or renamed. - */ - private function writeOverrides(array $overrides): void { - ksort($overrides); - - $content = "overridesPath); - $tmp = @tempnam($dir, '.modules_tmp_'); - if ($tmp === false) { - throw new \RuntimeException('Unable to create temporary file for config/modules.php'); - } - - try { - if (@file_put_contents($tmp, $content, LOCK_EX) === false) { - throw new \RuntimeException('Unable to write config/modules.php (temp stage)'); - } - - @chmod($tmp, 0644); - - if (!@rename($tmp, $this->overridesPath)) { - throw new \RuntimeException('Unable to atomically replace config/modules.php'); - } - - // config/modules.php is read back via require(), which OPcache caches - // (validate_timestamps + revalidate_freq). Install does several - // read-modify-write cycles in one request (setState → recordInstalled - // → recordSource); without invalidation the later reads see a STALE - // array and the final write clobbers the module's state, leaving it - // not-Enabled until a manual disable/enable. Drop the cached entry so - // every readOverrides() in this request sees what we just wrote. - if (function_exists('opcache_invalidate')) { - opcache_invalidate($this->overridesPath, true); - } - } catch (\Throwable $e) { - @unlink($tmp); - throw $e; - } - } - - /** - * Extract a module archive (.zip or .tar.gz/.tgz/.tar) into $destination. - * - * The type is detected by magic bytes (uploads arrive as an extension-less tmp - * file), then routed: - * - tar.gz : PharData (bundled with PHP, no extension) → fallback to `tar` CLI - * - zip : ZipArchive (validated) → fallback to the `unzip` CLI - * - * $destination is always an isolated temp dir created by the caller, and the - * CLI tools refuse to write outside it (they strip `../`/absolute members), so - * extraction stays contained even without the per-entry PHP validation. - * - * @throws \RuntimeException If the archive cannot be extracted in this environment. - */ - private function extractArchive(string $archivePath, string $destination): void { - if (!is_dir($destination) && !@mkdir($destination, 0755, true) && !is_dir($destination)) { - throw new \RuntimeException('Unable to create extraction directory.'); - } - - if ($this->looksLikeTar($archivePath)) { - $this->extractTarArchive($archivePath, $destination); - return; - } - - // ZIP: prefer the validated PHP extension, else the `unzip` CLI. - if (class_exists('ZipArchive')) { - $this->extractZipViaZipArchive($archivePath, $destination); - return; - } - if ($this->hasBinary('unzip')) { - // unzip exit 1 = success with warnings (e.g. skipped unsafe paths). - $this->runExtractor('unzip -oqq ' . escapeshellarg($archivePath) . ' -d ' . escapeshellarg($destination), 1); - return; - } - throw new \RuntimeException('Cannot extract .zip: install the PHP zip extension or the `unzip` command (or upload a .tar.gz).'); - } - - /** Detect a tar/tar.gz archive by extension, or gzip magic bytes for tmp uploads. */ - private function looksLikeTar(string $path): bool { - $lower = strtolower($path); - if (str_ends_with($lower, '.tar.gz') || str_ends_with($lower, '.tgz') || str_ends_with($lower, '.tar')) { - return true; - } - if (str_ends_with($lower, '.zip')) { - return false; - } - // Extension-less (uploaded tmp file): sniff magic. gzip = 1f 8b. - $fh = @fopen($path, 'rb'); - if ($fh === false) { - return false; - } - $magic = fread($fh, 3); - fclose($fh); - return strlen($magic) >= 2 && ord($magic[0]) === 0x1f && ord($magic[1]) === 0x8b; - } - - /** Extract .tar/.tar.gz via PharData (no PHP extension needed) or the `tar` CLI. */ - private function extractTarArchive(string $archivePath, string $destination): void { - if (class_exists('PharData')) { - try { - (new \PharData($archivePath))->extractTo($destination, null, true); - return; - } catch (\Throwable $e) { - // fall through to the CLI - } - } - if ($this->hasBinary('tar')) { - // GNU tar auto-detects gzip and strips unsafe (../, absolute) members. - $this->runExtractor('tar -xf ' . escapeshellarg($archivePath) . ' -C ' . escapeshellarg($destination), 0); - return; - } - throw new \RuntimeException('Cannot extract .tar.gz: PharData is unavailable and the `tar` command is missing.'); - } - - /** True if $bin resolves on PATH. */ - private function hasBinary(string $bin): bool { - $out = @shell_exec('command -v ' . escapeshellarg($bin) . ' 2>/dev/null'); - return is_string($out) && trim($out) !== ''; - } - - /** Run a CLI extractor; treat exit codes above $maxOkCode as failures. */ - private function runExtractor(string $cmd, int $maxOkCode): void { - $out = []; - $code = 0; - @exec($cmd . ' 2>&1', $out, $code); - if ($code > $maxOkCode) { - throw new \RuntimeException('Archive extraction failed (exit ' . $code . '): ' . implode(' ', array_slice($out, -3))); - } - } - - /** - * Safely extract a ZIP archive via the PHP zip extension. - * - * Validates each entry for path traversal attacks before extracting. - * - * @param string $zipFilePath Path to the zip file. - * @param string $destination Extraction target directory. - * @return void - * @throws \RuntimeException If extraction fails or unsafe entries are detected. - */ - private function extractZipViaZipArchive(string $zipFilePath, string $destination): void { - $zip = new \ZipArchive(); - if ($zip->open($zipFilePath) !== true) { - throw new \RuntimeException('Unable to open zip archive.'); - } - - try { - for ($i = 0; $i < $zip->numFiles; $i++) { - $entry = $zip->getNameIndex($i); - if ($entry === false || $entry === '') { - continue; - } - - $entry = str_replace('\\', '/', $entry); - if (strpos($entry, '../') !== false || strpos($entry, '..\\') !== false || strpos($entry, ':') !== false) { - throw new \RuntimeException('Unsafe zip entry detected.'); - } - - $targetPath = rtrim($destination, '/') . '/' . ltrim($entry, '/'); - - if (substr($entry, -1) === '/') { - if (!is_dir($targetPath) && !@mkdir($targetPath, 0755, true)) { - throw new \RuntimeException('Unable to create directory while extracting zip.'); - } - continue; - } - - $dir = dirname($targetPath); - if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { - throw new \RuntimeException('Unable to create directory while extracting zip.'); - } - - $in = $zip->getStream($entry); - if (!$in) { - throw new \RuntimeException('Unable to read zip entry stream.'); - } - - $out = @fopen($targetPath, 'wb'); - if (!$out) { - fclose($in); - throw new \RuntimeException('Unable to write extracted file.'); - } - - while (!feof($in)) { - $chunk = fread($in, 8192); - if ($chunk === false) { - break; - } - fwrite($out, $chunk); - } - - fclose($in); - fclose($out); - } - } finally { - $zip->close(); - } - } - - /** - * Resolve the module root directory from the extracted temp path. - * - * Handles both flat and nested zip layouts. - * - * @param string $tempBase Temporary extraction directory. - * @return string Path to the directory containing module.json. - * @throws \RuntimeException If module.json is not found or ambiguous. - */ - private function resolveExtractedModuleDir(string $tempBase): string { - $rootJson = $tempBase . '/module.json'; - if (is_file($rootJson)) { - return $tempBase; - } - - $jsonFiles = glob($tempBase . '/*/module.json') ?: []; - if (count($jsonFiles) !== 1) { - throw new \RuntimeException('Archive must contain exactly one module with module.json.'); - } - - return dirname($jsonFiles[0]); - } - - /** - * The module's canonical name from its module.json ("name"), falling back to - * the directory basename. The manifest is authoritative: extracting a flat - * archive (module.json at the root) or a differently-named wrapper dir would - * otherwise yield the random temp-dir name (e.g. "xc_module_ab12"), which fails - * sanitizeModuleName() with "Invalid module name." - */ - private function manifestNameFromDir(string $moduleDir): string { - $meta = json_decode((string) @file_get_contents($moduleDir . '/module.json'), true); - $name = is_array($meta) ? trim((string) ($meta['name'] ?? '')) : ''; - return $name !== '' ? $name : basename($moduleDir); - } - - /** - * Recursively delete a directory and its contents. - * - * @param string $path Path to delete. - * @return void - */ - private function deleteDirectory(string $path): void { - if (!file_exists($path)) { - return; - } - - if (is_file($path) || is_link($path)) { - @unlink($path); - return; - } - - $items = scandir($path); - if (!$items) { - @rmdir($path); - return; - } - - foreach ($items as $item) { - if ($item === '.' || $item === '..') { - continue; - } - $this->deleteDirectory($path . '/' . $item); - } - - @rmdir($path); - } - - /** - * Recursively copy a directory. - * - * @param string $source Source directory path. - * @param string $destination Destination directory path. - * @return void - * @throws \RuntimeException If copying fails. - */ - private function copyDirectory(string $source, string $destination): void { - if (!is_dir($source)) { - throw new \RuntimeException('Source directory not found: ' . $source); - } - - if (!is_dir($destination) && !@mkdir($destination, 0755, true)) { - throw new \RuntimeException('Unable to create module directory.'); - } - - $items = scandir($source); - if (!$items) { - return; - } - - foreach ($items as $item) { - if ($item === '.' || $item === '..') { - continue; - } - - $src = $source . '/' . $item; - $dst = $destination . '/' . $item; - - if (is_dir($src)) { - $this->copyDirectory($src, $dst); - } else { - if (!@copy($src, $dst)) { - throw new \RuntimeException('Unable to copy file: ' . $item); - } - } - } - } + private string $modulesPath; + + private string $overridesPath; + + private string $archivesPath; + + private ?ServiceContainer $container; + + /** + * Initialize the module manager. + * + * @param string|null $modulesPath Path to the modules directory. + * @param string|null $overridesPath Path to the config/modules.php overrides file. + * @param ServiceContainer|null $container Service container for DB access and DI. + */ + public function __construct( + ?string $modulesPath = null, + ?string $overridesPath = null, + ?ServiceContainer $container = null + ) { + $this->modulesPath = $modulesPath ?: (defined('MAIN_HOME') ? MAIN_HOME . 'Modules' : dirname(__DIR__, 2) . '/Modules'); + $this->overridesPath = $overridesPath ?: (defined('CONFIG_PATH') ? CONFIG_PATH . 'modules.php' : dirname(__DIR__, 2) . '/config/modules.php'); + $this->archivesPath = defined('MAIN_HOME') ? MAIN_HOME . 'modules_archives' : dirname(__DIR__, 2) . '/modules_archives'; + $this->container = $container; + } + + /** + * Absolute path of the stored archive for a custom (non-store) module. + * + * MAIN keeps a copy of every uploaded module archive named + * {name}_{version}.zip so LB servers can pull it back over the internal + * system API (action=getFile). Because every server shares the same + * MAIN_HOME layout, the LB can reconstruct this exact path from the name + + * version carried in the install_module signal. + */ + public function archivePathFor(string $name, string $version): string { + $name = $this->sanitizeModuleName($name); + $version = preg_replace('/[^0-9A-Za-z._\-]/', '', (string) $version); + return $this->archivesPath . '/' . $name . '_' . $version . '.zip'; + } + + /** @return object|null Database instance from the container, or null if unavailable. */ + private function getDb(): ?object { + if ($this->container !== null && $this->container->has('db')) { + return $this->container->get('db'); + } + return null; + } + + /** + * Absolute path of a module's directory on disk, resolving the + * `{name}_{hash5}` directory convention. + * + * A module's logical name (config key, class namespace) never carries the hash + * — sanitizeModuleName() forbids `_`. The directory does: `{name}_{hash5}` (5 + * hex chars of hash_id), so two modules that share a name don't clash on disk. + * Resolution order: exact `{name}` (legacy / back-compat) → the first + * `{name}_*` directory that has a module.json → fall back to exact. + */ + private function modulePathFor(string $name): string { + $name = $this->sanitizeModuleName($name); + $exact = $this->modulesPath . '/' . $name; + if (is_dir($exact)) { + return $exact; + } + foreach (glob($this->modulesPath . '/' . $name . '_*', GLOB_ONLYDIR) ?: [] as $dir) { + if (is_file($dir . '/module.json')) { + return $dir; + } + } + return $exact; + } + + /** + * Directory name for a module: `{name}_{hash5}` where hash5 is the first 5 hex + * chars of its permanent hash_id. The name itself never contains `_`. + * + * A hash_id is mandatory — callers must run the manifest through ensureHashId() + * first so no module is ever placed in a hash-less directory. Passing an empty + * hash_id is a programming error and throws. + */ + private function moduleDirName(string $name, string $hashId): string { + $name = $this->sanitizeModuleName($name); + $hashId = strtolower(preg_replace('/[^a-f0-9]/i', '', $hashId) ?? ''); + if ($hashId === '') { + throw new \RuntimeException("module '{$name}' has no hash_id — cannot build directory name"); + } + return $name . '_' . substr($hashId, 0, 5); + } + + /** + * Ensure the module at $moduleDir has a permanent hash_id, generating and + * persisting one into its module.json when absent. Returns the 32-hex value. + * + * Every module must carry a hash_id: it forms the `{name}_{hash5}` directory + * suffix and is the module's stable identity. Uploaded or legacy modules that + * ship without one get a fresh random id written here (once, mirroring + * tools/gen-module-hashes.php); a valid existing id is immutable and left as-is. + */ + private function ensureHashId(string $moduleDir): string { + $file = $moduleDir . '/module.json'; + $meta = json_decode((string) @file_get_contents($file), true); + if (!is_array($meta)) { + $meta = []; + } + $hash = strtolower(preg_replace('/[^a-f0-9]/i', '', (string) ($meta['hash_id'] ?? '')) ?? ''); + + if (strlen($hash) < 32) { + $hash = bin2hex(random_bytes(16)); + $meta = $this->withHashIdAfterName($meta, $hash); + @file_put_contents( + $file, + json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n" + ); + } + return $hash; + } + + /** + * Return $meta with hash_id set immediately after the `name` key (or first when + * there is no name), dropping any pre-existing empty hash_id entry. + * + * @param array $meta + * @return array + */ + private function withHashIdAfterName(array $meta, string $hash): array { + $out = []; + foreach ($meta as $k => $v) { + if ($k === 'hash_id') { + continue; + } + $out[$k] = $v; + if ($k === 'name') { + $out['hash_id'] = $hash; + } + } + if (!isset($out['hash_id'])) { + $out = ['hash_id' => $hash] + $out; + } + return $out; + } + + /** + * Copy an extracted module directory into modulesPath as `{name}_{hash5}`, + * replacing any existing install of the same module (one per name is active). + * + * @param string $moduleDir Extracted directory containing module.json. + * @return string Canonical module name that was placed. + */ + private function placeModuleFiles(string $moduleDir): string { + $name = $this->sanitizeModuleName($this->manifestNameFromDir($moduleDir)); + // Guarantee a hash_id (generating + persisting one when the upload lacks it) + // so the module is always placed in a `{name}_{hash5}` directory, never bare. + $hash = $this->ensureHashId($moduleDir); + + // Remove any current install of the same module (may live under a different + // {name}_{hash5} or a legacy {name} directory). + $existing = $this->modulePathFor($name); + if (is_dir($existing)) { + $this->deleteDirectory($existing); + } + + $targetDir = $this->modulesPath . '/' . $this->moduleDirName($name, $hash); + if (is_dir($targetDir)) { + $this->deleteDirectory($targetDir); + } + $this->copyDirectory($moduleDir, $targetDir); + + return $name; + } + + /** + * Retire legacy hash-less module directories: rename every bare `{name}` + * directory to the canonical `{name}_{hash5}`, generating a hash_id when the + * manifest lacks one. + * + * Older deployments (and modules placed before the hash convention) live in a + * bare `{name}` directory. This one-shot, idempotent migration — run on every + * `status` pass before anything scans the modules folder — brings them onto the + * `{name}_{hash5}` scheme so the legacy layout can be dropped. Already-hashed + * directories and non-module directories are skipped. When both a bare and a + * hashed copy of the same module exist, the stale bare copy is removed. + * + * @return string[] Canonical names of modules migrated this pass. + */ + public function migrateLegacyModuleDirs(): array { + $migrated = []; + foreach (glob($this->modulesPath . '/*', GLOB_ONLYDIR) ?: [] as $dir) { + if (!is_file($dir . '/module.json')) { + continue; + } + $name = $this->sanitizeModuleName($this->manifestNameFromDir($dir)); + if ($name === '') { + continue; + } + // A canonical `{name}_{hash5}` directory has a basename different from the + // logical name; only a bare `{name}` directory is legacy. + if (basename($dir) !== $name) { + continue; + } + + $hash = $this->ensureHashId($dir); + $target = $this->modulesPath . '/' . $this->moduleDirName($name, $hash); + if ($target === $dir) { + continue; + } + if (is_dir($target)) { + // A hashed copy already exists — the bare directory is a stale dup. + $this->deleteDirectory($dir); + } else { + @rename($dir, $target); + } + $migrated[] = $name; + } + return $migrated; + } + + /** + * Remove on-disk copies and recorded state of modules that are now part of + * the core codebase (ModuleLoader::CORE_PROVIDED_MODULES). + * + * Upgraded panels may still carry the old module directory (any hash + * suffix); left in place it would boot alongside the core implementation + * and its commands/routes would collide. + * + * @return string[] Names that were purged. + */ + public function purgeCoreProvidedModules(): array { + $purged = []; + foreach (ModuleLoader::CORE_PROVIDED_MODULES as $name) { + $dir = $this->modulePathFor($name); + if (is_dir($dir) && is_file($dir . '/module.json')) { + $this->deleteDirectory($dir); + error_log("purgeCoreProvidedModules: removed stale module directory '" . basename($dir) . "' — '{$name}' now ships in core."); + $purged[] = $name; + } + + $overrides = $this->readOverrides(); + if (isset($overrides[$name])) { + unset($overrides[$name]); + $this->writeOverrides($overrides); + if (!in_array($name, $purged, true)) { + $purged[] = $name; + } + } + } + return $purged; + } + + /** + * Names of currently-installed modules that declare $name as a dependency. + * + * @return string[] + */ + private function installedDependentsOf(string $name): array { + $out = []; + foreach ($this->listModules() as $module) { + if (($module['installed_version'] ?? '') === '') { + continue; // not installed — its requirements don't apply + } + if (in_array($name, $module['dependencies'] ?? [], true)) { + $out[] = $module['name']; + } + } + return $out; + } + + /** + * Names of currently-loadable (enabled) installed modules that declare $name + * as a required dependency. + * + * Used to guard disabling: a dependent that is itself disabled won't be loaded + * either, so disabling $name under it is harmless and must not be blocked. + * Only enabled dependents would be broken (ModuleLoader skips them on the next + * boot), so only those count. + * + * @return string[] + */ + private function enabledDependentsOf(string $name): array { + $out = []; + foreach ($this->listModules() as $module) { + if (($module['installed_version'] ?? '') === '') { + continue; // not installed — its requirements don't apply + } + $state = $module['state'] ?? null; + if (!($state instanceof ModuleState) || !$state->isLoadable()) { + continue; // already disabled/failed — disabling its dep won't break it + } + if (in_array($name, $module['dependencies'] ?? [], true)) { + $out[] = $module['name']; + } + } + return $out; + } + + /** + * Install any on-disk module that has never been installed. + * + * Bundled modules (e.g. ministra) are booted on every request but only get + * their install() / migrations run when explicitly installed. On a fresh + * panel nothing would create their tables, so this runs once during the + * migrate step (see StatusCommand) to provision them. It is idempotent: + * already-installed modules are skipped, and a module's up-migrations use + * CREATE TABLE IF NOT EXISTS so re-provisioning an existing panel is safe. + * + * Modules are installed in dependency order. Only admin-disabled modules are + * left untouched — a module whose previous install FAILED (or crashed mid-way, + * leaving it Installing) is retried here, so re-running `console.php status` + * self-heals once the root cause is fixed. Retrying is safe: the master schema + * uses CREATE TABLE IF NOT EXISTS. + * + * @return string[] Names of modules that were installed this pass. + */ + public function syncBundledModules(): array { + // Retire any legacy hash-less {name} directory before anything scans the + // modules folder, so every module ends up on the {name}_{hash5} scheme. + $this->migrateLegacyModuleDirs(); + + // Drop leftovers of modules whose functionality moved into the core + // (e.g. tmdb): a stale on-disk copy would still boot and its commands + // would collide with the core-registered ones. + $this->purgeCoreProvidedModules(); + + // Fetch any standard-set module that lives in a remote source (git/url/ + // platform) and isn't on disk yet — a no-op while every standard module is + // bundled on-disk. Fetched modules are installed by provisionStandardSet(), + // so the on-disk pass below simply skips them. + $provisioned = $this->provisionStandardSet(); + + $modules = $this->listModules(); + $installed = []; + foreach ($modules as $module) { + if (($module['installed_version'] ?? '') !== '') { + $installed[$module['name']] = true; + } + } + + // Candidates: present on disk, not yet installed, not admin-disabled. + // Failed/Installing (a never-completed install) are retried, not skipped. + $pending = []; + foreach ($modules as $module) { + $name = $module['name']; + if (isset($installed[$name])) { + continue; + } + if (($module['state'] ?? null) === ModuleState::Disabled) { + continue; + } + $pending[$name] = $module; + } + + // Install in dependency order: a module installs once all its declared + // dependencies are themselves installed. + $done = []; + $guard = 0; + while (!empty($pending) && $guard++ < 1000) { + $progressed = false; + foreach (array_keys($pending) as $name) { + $ready = true; + foreach ($pending[$name]['dependencies'] ?? [] as $dep) { + if (!isset($installed[$dep])) { + $ready = false; + break; + } + } + if (!$ready) { + continue; + } + try { + $this->installModule($name); + $installed[$name] = true; + $done[] = $name; + } catch (\Throwable $e) { + error_log("syncBundledModules: install '{$name}' failed: " . $e->getMessage()); + } + unset($pending[$name]); + $progressed = true; + } + if (!$progressed) { + break; // unmet or circular dependencies — stop + } + } + + return array_values(array_unique(array_merge($provisioned, $done))); + } + + /** + * The standard module set the panel provisions by default (config/bundled_modules.php). + * + * Foundation for modules-in-separate-repos: each entry is keyed by the module's + * permanent `hash_id`. When the config file is absent, falls back to whatever + * is on disk (treated as `bundled`). + * + * @return array + */ + public function getStandardSet(): array { + $path = defined('CONFIG_PATH') + ? CONFIG_PATH . 'bundled_modules.php' + : dirname(__DIR__, 2) . '/config/bundled_modules.php'; + + if (is_file($path)) { + $data = require $path; + if (is_array($data)) { + return array_values(array_filter($data, 'is_array')); + } + } + + // Fallback: derive from on-disk modules (all treated as bundled). + $out = []; + foreach ($this->listModules() as $m) { + $out[] = ['hash_id' => (string) ($m['hash_id'] ?? ''), 'name' => $m['name'], 'source' => 'bundled']; + } + return $out; + } + + /** + * Resolve the on-disk module name that carries a given permanent `hash_id`. + * + * The stable identity lookup: lets the panel recognise "the same module" across + * a rename or a repo move, where `name` alone is unreliable. + * + * @param string $hashId Permanent module hash_id. + * @return string|null Module name, or null if no on-disk module has that hash_id. + */ + public function findModuleByHashId(string $hashId): ?string { + $hashId = trim($hashId); + if ($hashId === '') { + return null; + } + foreach ($this->listModules() as $m) { + if (($m['hash_id'] ?? '') === $hashId) { + return $m['name']; + } + } + return null; + } + + /** + * Provision the standard set: fetch+install any standard-set module that lives + * in a remote source (git/url/platform) and is not on disk yet. + * + * `bundled` entries and modules already present (matched by `hash_id`) are left + * to syncBundledModules()'s on-disk install. Per-entry failures are logged, not + * fatal. No-op today (every standard module is bundled on-disk). + * + * @return string[] Names/hash_ids of modules fetched this pass. + */ + public function provisionStandardSet(): array { + $done = []; + foreach ($this->getStandardSet() as $entry) { + $hash = (string) ($entry['hash_id'] ?? ''); + $name = (string) ($entry['name'] ?? ''); + + // Already on disk (bundled or previously fetched)? Nothing to fetch. + // A same-name directory whose identity does NOT match the pinned + // hash_id is a stale pre-pin copy (e.g. a legacy-migrated bundled + // module from an older release) — it must be replaced, not kept: + // otherwise the panel runs outdated module code forever. + $onDisk = $hash !== '' ? $this->findModuleByHashId($hash) : null; + $staleDir = null; + if ($onDisk === null && $name !== '') { + $dir = $this->modulePathFor($name); + if (is_dir($dir) && is_file($dir . '/module.json')) { + if ($hash === '') { + $onDisk = $name; // no pin — any same-name copy counts + } else { + $staleDir = $dir; + } + } + } + if ($onDisk !== null) { + continue; + } + + $source = (string) ($entry['source'] ?? 'bundled'); + if ($source === 'bundled') { + error_log("provisionStandardSet: '{$name}' is declared bundled but missing on disk — skipped."); + continue; + } + + try { + if ($staleDir !== null) { + error_log("provisionStandardSet: '{$name}' on disk (" . basename($staleDir) . ") does not match the pinned hash_id — replacing with the pinned release."); + $this->deleteDirectory($staleDir); + } + $this->installModuleFromSource($entry); + $done[] = $name !== '' ? $name : $hash; + } catch (\Throwable $e) { + error_log("provisionStandardSet: fetch of '" . ($name !== '' ? $name : $hash) . "' failed: " . $e->getMessage()); + } + } + return $done; + } + + /** + * Fetch a NOT-yet-present module from a standard-set entry's source and install it. + * + * Mirrors updateModuleFromSource() but for a first install (no local module.json + * to read the source from — it comes from the entry). Verifies the fetched + * `hash_id` against the entry so a repo/URL cannot supply a different module. + * + * @param array $entry A getStandardSet() entry (source/repository/…, hash_id). + * @return void + * @throws \RuntimeException on download/verify/install failure. + */ + private function installModuleFromSource(array $entry): void { + $update = [ + 'source' => (string) ($entry['source'] ?? 'bundled'), + 'repository' => (string) ($entry['repository'] ?? ''), + 'channel' => (string) ($entry['channel'] ?? 'stable'), + 'slug' => (string) ($entry['slug'] ?? ($entry['name'] ?? '')), + 'url' => (string) ($entry['url'] ?? ''), + ]; + $expectedHash = (string) ($entry['hash_id'] ?? ''); + + // platform — the store install flow handles download/decrypt/license/LB. + if ($update['source'] === 'platform') { + $apiKey = (string) (SettingsManager::get('platform_api_key') ?? ''); + $slug = $update['slug'] !== '' ? $update['slug'] : (string) ($entry['name'] ?? ''); + $this->downloadFromPlatform($slug, '', $apiKey); + return; + } + + $version = (new ModuleUpdateChecker())->latestAvailable([ + 'update' => $update, + 'version' => '', + 'installed_version' => '', + ]); + if ($version === null) { + throw new \RuntimeException('No installable version resolved from source.'); + } + + [$url, $md5] = $this->resolveSourceDownload($update, $version); + if ($url === '') { + throw new \RuntimeException('No download URL resolved from source.'); + } + + $archive = (string) @tempnam(sys_get_temp_dir(), 'xc_modinst_'); + $tempBase = rtrim(sys_get_temp_dir(), '/') . '/xc_modinst_' . bin2hex(random_bytes(8)); + try { + $this->downloadToFile($url, $archive); + if ($md5 !== '' && !hash_equals(strtolower($md5), (string) md5_file($archive))) { + throw new \RuntimeException('Checksum mismatch on the downloaded module archive.'); + } + + $this->extractArchive($archive, $tempBase); + $moduleDir = $this->resolveExtractedModuleDir($tempBase); + + $meta = json_decode((string) @file_get_contents($moduleDir . '/module.json'), true); + $gotHash = is_array($meta) ? (string) ($meta['hash_id'] ?? '') : ''; + if ($expectedHash !== '' && $gotHash !== '' && !hash_equals($expectedHash, $gotHash)) { + throw new \RuntimeException('hash_id mismatch — fetched module is not the expected one.'); + } + + $name = $this->placeModuleFiles($moduleDir); + $manifest = $this->readModuleManifest($name); + $ver = (string) ($manifest['version'] ?? $version); + + $this->storeModuleArchive($archive, $name, $ver); + if ((string) ($this->readOverrides()[$name]['installed_version'] ?? '') !== '') { + // Files were re-provisioned for an already-installed module (a + // stale copy was replaced) — catch the schema up incrementally + // instead of re-running the initial install migrations. + $this->updateModule($name); + } else { + $this->installModule($name, $ver); + } + $this->recordModuleSource($name, 'local'); + $this->distributeToLoadBalancers($name, $manifest, 'local', $ver); + } finally { + @unlink($archive); + $this->deleteDirectory($tempBase); + } + } + + /** + * List all installed modules with their metadata and status. + * + * Scans the modules directory for module.json files, merges with + * config/modules.php overrides, and returns sorted results. + * + * @return array Module list. + */ + public function listModules(): array { + $overrides = $this->readOverrides(); + $items = []; + + $jsonFiles = glob($this->modulesPath . '/*/module.json') ?: []; + + // Pre-resolve each module's state by name so the dependency diagnostics + // below can see the full set while building items. + $stateByName = []; + foreach ($jsonFiles as $jsonFile) { + $meta = json_decode((string) @file_get_contents($jsonFile), true) ?: []; + // Key by the CANONICAL manifest name, not the `{name}_{hash5}` directory — + // config/modules.php and `dependencies` both reference the logical name. + $depName = (string) ($meta['name'] ?? basename(dirname($jsonFile))); + $stateByName[$depName] = ModuleState::fromRaw( + $overrides[$depName]['state'] ?? ($overrides[$depName]['enabled'] ?? null) + ); + } + + foreach ($jsonFiles as $jsonFile) { + $meta = json_decode((string) @file_get_contents($jsonFile), true) ?: []; + $name = (string) ($meta['name'] ?? basename(dirname($jsonFile))); // canonical name + $state = $stateByName[$name] ?? ModuleState::fromRaw(null); + + $dependencies = ModuleLoader::filterCoreProvidedDependencies( + is_array($meta['dependencies'] ?? null) ? $meta['dependencies'] : [] + ); + + // Flag a module that is nominally Enabled but won't actually load: + // ModuleLoader skips it when a required dependency is missing or not + // loadable (e.g. plex is Enabled but watch is Failed/Disabled). Mirrors + // ModuleLoader::pruneUnsatisfiableModules(). + $dependencyWarnings = []; + foreach ($dependencies as $dep) { + if (!isset($stateByName[$dep])) { + $dependencyWarnings[] = "Required dependency '{$dep}' is missing."; + } elseif (!$stateByName[$dep]->isLoadable()) { + $dependencyWarnings[] = "Required dependency '{$dep}' is not enabled (" + . $stateByName[$dep]->value . ').'; + } + } + + $items[] = [ + 'name' => $name, + 'hash_id' => (string) ($meta['hash_id'] ?? ''), + 'update' => ModuleLoader::normalizeUpdateBlock($meta, $name), + 'description' => $meta['description'] ?? '', + 'version' => $meta['version'] ?? '', + 'requires_core' => $meta['requires_core'] ?? '', + 'environment' => $meta['environment'] ?? 'main', + 'priority' => (int) ($meta['priority'] ?? 0), + 'dependencies' => $dependencies, + 'optional_dependencies' => is_array($meta['optional_dependencies'] ?? null) ? $meta['optional_dependencies'] : [], + 'has_navbar' => (bool) ($meta['has_navbar'] ?? false), + 'has_settings' => (bool) ($meta['has_settings'] ?? false), + 'enabled' => $state->isLoadable(), + 'state' => $state, + 'path' => dirname($jsonFile), + 'installed_version' => $overrides[$name]['installed_version'] ?? '', + 'available_version' => $overrides[$name]['available_version'] ?? '', + 'source' => $overrides[$name]['source'] ?? '', + 'previous_version' => $overrides[$name]['previous_version'] ?? '', + 'dependency_warnings' => $dependencyWarnings, + ]; + } + + usort($items, function ($a, $b) { + return strcmp($a['name'], $b['name']); + }); + + return $items; + } + + /** + * Install a module by name. + * + * Loads the module instance, runs install(), and enables it. + * + * @param string $name Module name (lowercase, alphanumeric + hyphens). + * @return void + * @throws \RuntimeException If the module cannot be loaded. + */ + public function installModule(string $name, ?string $version = null): void { + $name = $this->sanitizeModuleName($name); + $module = $this->loadModuleInstance($name); + + // Version priority: explicit $version (platform installs pass the + // authoritative SaaS release version) → module.json manifest → the + // module's hardcoded getVersion() (which can drift from the manifest). + $targetVersion = $version ?? $this->manifestVersion($name) ?? $module->getVersion(); + $modulePath = $this->modulePathFor($name); + + $this->setState($name, ModuleState::Installing); + + try { + $db = $this->getDb() ?? DatabaseFactory::get(); + // Apply the module's master schema, then its own install() hook for any + // non-SQL setup. NB: schema files are DDL (CREATE/ALTER), which + // MySQL/MariaDB implicitly commit — a wrapping transaction gives no + // rollback safety, and its rollback() on error throws "no active + // transaction", masking the real SQL error. So run directly and let the + // genuine failure propagate to the catch below (and into the logs). + if ($db !== null) { + // Fresh install applies the module's master schema (database.sql). + ModuleMigrator::install($modulePath, $db, (string) $targetVersion); + } + $module->install(); + } catch (\Throwable $e) { + $this->setState($name, ModuleState::Failed); + throw $e; + } + + $this->setState($name, ModuleState::Enabled); + $this->recordInstalledVersion($name, $targetVersion); + } + + /** + * Uninstall a module by name. + * + * Runs uninstall() and disables the module. + * + * @param string $name Module name. + * @return void + * @throws \RuntimeException If the module cannot be loaded. + */ + public function uninstallModule(string $name): void { + $name = $this->sanitizeModuleName($name); + + // Refuse to remove a module that still-installed dependents rely on + // (e.g. plex depends on watch — watch cannot be removed under it). + $dependents = $this->installedDependentsOf($name); + if (!empty($dependents)) { + throw new \RuntimeException( + "Cannot uninstall '{$name}': still required by " . implode(', ', $dependents) + . '. Uninstall ' . (count($dependents) === 1 ? 'it' : 'them') . ' first.' + ); + } + + $module = $this->loadModuleInstance($name); + + // The module's own uninstall() hook runs first (it clears the data/rows + // it created), then the module's schema is torn down via its single + // teardown file (database_drop.sql). + $module->uninstall(); + $db = $this->getDb() ?? DatabaseFactory::get(); + if ($db !== null) { + ModuleMigrator::uninstall($this->modulePathFor($name), $db); + } + + $this->clearInstalledVersion($name); + $this->setState($name, ModuleState::Disabled); + } + + /** + * Fully delete a module: uninstall it, then remove its directory from disk and + * its override entry from config/modules.php. + * + * Unlike uninstallModule() — which drops the tables and disables the module but + * leaves its files on disk so it stays listed and re-installable — this removes + * the module entirely. For a BUNDLED module (shipped in the deploy) the files + * come back on the next panel update; deletion is still honoured until then. + * + * Order matters: uninstall runs FIRST (drop tables + uninstall() hook) while the + * files are still on disk (the teardown SQL and the module class live there); + * only after a clean uninstall are the files removed, so no orphaned tables are + * left behind. A failing uninstall aborts the delete. The dependents guard is + * enforced up front. + * + * @param string $name Module name. + * @return void + * @throws \RuntimeException If an installed dependent still requires the module. + */ + public function deleteModule(string $name): void { + $name = $this->sanitizeModuleName($name); + + // Same guard as uninstall: refuse while an installed dependent needs it. + $dependents = $this->installedDependentsOf($name); + if (!empty($dependents)) { + throw new \RuntimeException( + "Cannot delete '{$name}': still required by " . implode(', ', $dependents) + . '. Delete ' . (count($dependents) === 1 ? 'it' : 'them') . ' first.' + ); + } + + // Read the manifest (for LB propagation) BEFORE the files are removed. + $manifest = []; + try { + $manifest = $this->readModuleManifest($name); + } catch (\Throwable $e) { + // No readable manifest — LB propagation just skips below. + } + + // Step 1 — uninstall FIRST, while the module's files are still on disk: + // run its uninstall() hook and drop its tables (teardown SQL + module class + // both live in the directory). A failure aborts the delete and propagates, + // so the files are never removed with orphaned tables left behind. + $this->uninstallModule($name); + + // Step 2 — remove the files, stored archives and the config override. + $this->deleteModuleFilesOnly($name); + + // Step 3 — propagate the deletion to every LB node that received this module. + $this->distributeDeletionToLoadBalancers($name, $manifest); + } + + /** + * Remove a module's files WITHOUT touching the database. + * + * Used on LB nodes (which share MAIN's database — dropping tables there would + * delete MAIN's data) and as the file-removal step of deleteModule(). Removes + * the module directory, its stored archives, and its config/modules.php entry. + * + * @param string $name Module name. + * @return void + */ + public function deleteModuleFilesOnly(string $name): void { + $name = $this->sanitizeModuleName($name); + + $modulePath = $this->modulePathFor($name); + if (is_dir($modulePath)) { + $this->deleteDirectory($modulePath); + } + + // Remove any stored marketplace archives (name_.zip). + foreach (glob($this->archivesPath . '/' . $name . '_*.zip') ?: [] as $rArchive) { + @unlink($rArchive); + } + + // Drop the config/modules.php override entry entirely. + $overrides = $this->readOverrides(); + if (isset($overrides[$name])) { + unset($overrides[$name]); + $this->writeOverrides($overrides); + } + } + + /** + * Tell every load balancer that received this module to delete it too. + * + * Mirrors distributeToLoadBalancers(): only MAIN dispatches, and only for + * modules that were distributed (environment lb/any). LB nodes act on the + * `delete_module` signal via RootSignalsCronJob → `console.php module:delete` + * (files-only — never a DB drop on the shared MAIN database). + * + * @param string $name Module name. + * @param array $manifest The module's manifest (read before deletion). + * @return void + */ + private function distributeDeletionToLoadBalancers(string $name, array $manifest): void { + // Cheap manifest check first — a MAIN-only module was never on any LB, so + // return before touching config (isLoadBalancer() reads via the extension). + $environment = strtolower((string) ($manifest['environment'] ?? 'main')); + if (!in_array($environment, ['lb', 'any'], true)) { + return; // MAIN-only — LB never had it + } + if ($this->isLoadBalancer()) { + return; + } + $db = $this->getDb(); + if ($db === null) { + return; + } + + $payload = json_encode(['action' => 'delete_module', 'name' => $name]); + + $db->query('SELECT `id` FROM `servers` WHERE `server_type` = 0 AND `is_main` = 0 AND `enabled` = 1;'); + $rServerIDs = []; + foreach ($db->get_rows() as $rRow) { + $rServerIDs[] = intval($rRow['id']); + } + foreach ($rServerIDs as $rServerID) { + $db->query( + 'INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', + $rServerID, + time(), + $payload + ); + } + } + + /** + * Update a module, running only the incremental migrations needed. + * + * Reads the recorded installed_version from config/modules.php. + * If no version is recorded (legacy install), falls back to full installModule(). + * If already at the current version, does nothing. + * Otherwise runs all getMigrations() entries with version > installedVersion + * and version <= module->getVersion(), in ascending semver order. + * + * @param string $name Module name. + * @return void + */ + public function updateModule(string $name): void { + $name = $this->sanitizeModuleName($name); + $overrides = $this->readOverrides(); + $fromVersion = $overrides[$name]['installed_version'] ?? null; + + if ($fromVersion === null) { + $this->installModule($name); + return; + } + + $module = $this->loadModuleInstance($name); + $toVersion = $this->manifestVersion($name) ?? $module->getVersion(); + + if (version_compare($fromVersion, $toVersion, '>=')) { + return; + } + + // File-based schema migrations: every up file in (fromVersion, toVersion]. + $db = $this->getDb() ?? DatabaseFactory::get(); + if ($db !== null) { + ModuleMigrator::up($this->modulePathFor($name), $db, $fromVersion, (string) $toVersion); + } + + // Programmatic migrations (callables) — coexist with the file-based ones. + if ($module instanceof MigratableInterface) { + $this->runPendingMigrations($module->getMigrations(), $fromVersion, $toVersion); + } + + $this->recordInstalledVersion($name, $toVersion); + } + + /** + * Update a module by fetching new files from its declared source, then running + * migrations. This is what the panel's "Update" button triggers (P4). + * + * - bundled : files already ship with the panel → migrations only (updateModule()). + * - platform : delegated to the store flow (downloadFromPlatform — backup/restore/LB inside). + * - git/url : download archive → verify `hash_id` → backup → replace files → migrate → + * restore on failure → distribute to LB. + * + * Identity pinning: for git/url the fetched module.json `hash_id` must equal the + * installed one — a repo/URL cannot impersonate another module or hijack a rename. + * + * @param string $name Module name. + * @return string|null Version now on disk after the update, or null when the + * source had nothing newer (stale available_version cleared). + * @throws \RuntimeException on download/verify/apply failure (files rolled back). + */ + public function updateModuleFromSource(string $name): ?string { + $name = $this->sanitizeModuleName($name); + $manifest = $this->readModuleManifest($name); + $overrides = $this->readOverrides(); + + $update = ModuleLoader::normalizeUpdateBlock($manifest, $name); + $installed = (string) ($overrides[$name]['installed_version'] ?? ''); + $source = $update['source']; + + // bundled — the panel already replaced the files; just catch the schema up. + if ($source === 'bundled') { + $this->updateModule($name); + $this->recordAvailableVersion($name, null); + return $this->manifestVersion($name); + } + + // platform — reuse the full store flow (self-contained rollback + LB fan-out). + if ($source === 'platform') { + $apiKey = (string) (SettingsManager::get('platform_api_key') ?? ''); + $this->downloadFromPlatform(($update['slug'] !== '' ? $update['slug'] : $name), '', $apiKey); + $this->recordAvailableVersion($name, null); + return $this->manifestVersion($name); + } + + // git / url — fetch, verify, apply. + $checker = new ModuleUpdateChecker(); + $version = $checker->latestAvailable([ + 'update' => $update, + 'version' => (string) ($manifest['version'] ?? ''), + 'installed_version' => $installed, + ]); + if ($version === null && $checker->lastError() !== null) { + // Source unreachable (e.g. GitHub API rate limit) — fail loudly instead + // of silently doing nothing while the caller reports "updated". + throw new \RuntimeException( + "Cannot resolve the latest version of '{$name}' from its {$source} source: " . $checker->lastError() + ); + } + if ($version === null || ($installed !== '' && version_compare($version, $installed, '<='))) { + // Nothing newer at the source — the recorded available_version is stale + // (already updated, or the release was pulled). Clear it so the Update + // button disappears instead of reappearing forever. + $this->recordAvailableVersion($name, null); + return null; + } + + [$downloadUrl, $expectedMd5] = $this->resolveSourceDownload($update, $version); + if ($downloadUrl === '') { + throw new \RuntimeException("No download URL resolved for module '{$name}' (source '{$source}')."); + } + + $archive = (string) @tempnam(sys_get_temp_dir(), 'xc_modupd_'); + if ($archive === '') { + throw new \RuntimeException('Unable to create a temp file for the module download.'); + } + $tempBase = rtrim(sys_get_temp_dir(), '/') . '/xc_modupd_' . bin2hex(random_bytes(8)); + + try { + $this->downloadToFile($downloadUrl, $archive); + if ($expectedMd5 !== '' && !hash_equals(strtolower($expectedMd5), (string) md5_file($archive))) { + throw new \RuntimeException('Checksum mismatch on the downloaded module archive.'); + } + + $this->extractArchive($archive, $tempBase); + $moduleDir = $this->resolveExtractedModuleDir($tempBase); + + // Identity pinning — the fetched module must be the SAME module. + $newMeta = json_decode((string) @file_get_contents($moduleDir . '/module.json'), true); + $newHash = is_array($newMeta) ? (string) ($newMeta['hash_id'] ?? '') : ''; + $ownHash = (string) ($manifest['hash_id'] ?? ''); + if ($ownHash !== '' && $newHash !== '' && !hash_equals($ownHash, $newHash)) { + throw new \RuntimeException("hash_id mismatch — refusing to overwrite '{$name}' with a different module."); + } + + $targetDir = $this->modulePathFor($name); + $backupDir = $this->backupModuleDir($name, $targetDir); + try { + $this->copyDirectory($moduleDir, $targetDir); + $this->updateModule($name); // incremental migrations to the new manifest version + + $fresh = $this->readModuleManifest($name); + $resolvedVer = (string) ($fresh['version'] ?? $version); + $this->recordAvailableVersion($name, null); + + // Keep a local archive so LB nodes can pull it (getFile), then fan out. + $this->storeModuleArchive($archive, $name, $resolvedVer); + $this->distributeToLoadBalancers($name, $fresh, 'local', $resolvedVer); + + if ($backupDir !== null) { + $this->deleteDirectory($backupDir); + } + + return $resolvedVer; + } catch (\Throwable $e) { + $this->restoreModuleBackup($name, $targetDir, $backupDir, $installed !== '' ? $installed : null); + throw new \RuntimeException("Update of '{$name}' failed — rolled back: " . $e->getMessage(), 0, $e); + } + } finally { + @unlink($archive); + $this->deleteDirectory($tempBase); + } + } + + /** + * Resolve the download URL (+ optional expected md5) for a git/url source. + * + * git : release asset `module.tar.gz` at the tag == $version; md5 (if any) + * comes from the release's `hashes.md5` via GitHubReleases::getAssetHash(). + * url : re-fetch the `version.json` for its `download` (https) and optional `md5`. + * + * @return array{0:string,1:string} [downloadUrl, expectedMd5] — url '' if unresolved. + */ + private function resolveSourceDownload(array $update, string $version): array { + if (($update['source'] ?? '') === 'git') { + if (!preg_match('~github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$~i', (string) ($update['repository'] ?? ''), $m)) { + return ['', '']; + } + $asset = 'module.tar.gz'; // convention: module repo release ships this asset + $url = "https://github.com/{$m[1]}/{$m[2]}/releases/download/{$version}/{$asset}"; + $md5 = ''; + try { + $channel = in_array((string) ($update['channel'] ?? 'stable'), ['beta', 'unstable'], true) ? 'beta' : 'stable'; + $md5 = (string) ((new GitHubReleases($m[1], $m[2], $channel))->getAssetHash($version, $asset) ?? ''); + } catch (\Throwable $e) { + // no hash available → download proceeds unverified + } + return [$url, $md5]; + } + + if (($update['source'] ?? '') === 'url') { + $data = json_decode($this->httpGetString((string) ($update['url'] ?? '')), true); + $dl = is_array($data) ? trim((string) ($data['download'] ?? '')) : ''; + $md5 = is_array($data) ? trim((string) ($data['md5'] ?? '')) : ''; + if (stripos($dl, 'https://') !== 0) { + $dl = ''; + } + return [$dl, $md5]; + } + + return ['', '']; + } + + /** cURL GET a small https resource to a string ('' on failure/non-https). */ + private function httpGetString(string $url): string { + if (stripos($url, 'https://') !== 0) { + return ''; + } + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_USERAGENT => 'XC_VM-ModuleManager', + ]); + $body = curl_exec($ch); + curl_close($ch); + return is_string($body) ? $body : ''; + } + + /** Download an https URL straight to $dest; throws on non-https / HTTP error. */ + private function downloadToFile(string $url, string $dest): void { + // Shared streaming primitive (see CurlClient::downloadToFile); kept as a + // thin wrapper so existing call sites and error text stay stable. + CurlClient::downloadToFile($url, $dest); + } + + /** + * Set the lifecycle state of a module in config/modules.php. + * + * When state is Enabled the 'state' key is removed entirely (clean default). + * When state is anything else the string value is persisted as 'state'. + * + * @param string $name Module name. + * @param ModuleState $state Target lifecycle state. + * @return void + */ + public function setState(string $name, ModuleState $state): void { + $name = $this->sanitizeModuleName($name); + + // Refuse to disable a module that still-enabled dependents rely on + // (e.g. plex requires watch — watch cannot be disabled under it, or + // ModuleLoader would skip plex on the next boot). Mirrors the guard in + // uninstallModule(). Scoped strictly to a deliberate Disabled transition: + // the internal lifecycle states (Installing, Failed) are also non-loadable + // but are set by installModule() itself and must never be blocked. + if ($state === ModuleState::Disabled) { + $dependents = $this->enabledDependentsOf($name); + if (!empty($dependents)) { + throw new \RuntimeException( + "Cannot disable '{$name}': still required by " . implode(', ', $dependents) + . '. Disable ' . (count($dependents) === 1 ? 'it' : 'them') . ' first.' + ); + } + } + + $overrides = $this->readOverrides(); + + if (!isset($overrides[$name]) || !is_array($overrides[$name])) { + $overrides[$name] = []; + } + + // Remove any legacy bool 'enabled' key — we use 'state' now. + unset($overrides[$name]['enabled']); + + if ($state === ModuleState::Enabled) { + // Enabled is the default: clean up the key so the file stays minimal. + unset($overrides[$name]['state']); + if (empty($overrides[$name])) { + unset($overrides[$name]); + } + } else { + $overrides[$name]['state'] = $state->value; + } + + $this->writeOverrides($overrides); + } + + /** + * Enable or disable a module in config/modules.php. + * + * @deprecated Use setState(name, ModuleState::Enabled / ModuleState::Disabled) instead. + * + * @param string $name Module name. + * @param bool $enabled True to enable, false to disable. + * @return void + */ + public function setEnabled(string $name, bool $enabled): void { + $this->setState($name, $enabled ? ModuleState::Enabled : ModuleState::Disabled); + } + + /** + * Upload a zip archive and install the module from it. + * + * Extracts the archive to a temp directory, validates structure, + * copies to the modules path, and runs installModule(). + * + * @param string $zipFilePath Path to the uploaded zip file. + * @return string Installed module name. + * @throws \RuntimeException If extraction or installation fails. + * @throws \InvalidArgumentException If the zip file is not found. + */ + public function uploadAndInstall(string $zipFilePath): string { + if (!is_file($zipFilePath)) { + throw new \InvalidArgumentException('Uploaded zip file not found.'); + } + + $tempBase = rtrim(sys_get_temp_dir(), '/') . '/xc_module_' . bin2hex(random_bytes(8)); + if (!@mkdir($tempBase, 0755, true) && !is_dir($tempBase)) { + throw new \RuntimeException('Unable to create temporary directory.'); + } + + try { + $this->extractArchive($zipFilePath, $tempBase); + + $moduleDir = $this->resolveExtractedModuleDir($tempBase); + $moduleName = $this->placeModuleFiles($moduleDir); + + $this->installModule($moduleName); + + // Keep a copy of the uploaded archive so it can be redistributed to + // LB servers (which have no access to the store for custom modules). + $manifest = $this->readModuleManifest($moduleName); + $version = (string) ($manifest['version'] ?? '0.0.0'); + $this->storeModuleArchive($zipFilePath, $moduleName, $version); + + // Custom (non-store) module — no store rollback available. + $this->recordModuleSource($moduleName, 'local'); + + // If the manifest targets load balancers, push the archive to every LB. + $this->distributeToLoadBalancers($moduleName, $manifest, 'local', $version); + + return $moduleName; + } finally { + $this->deleteDirectory($tempBase); + } + } + + /** + * LB-side: install a custom (non-store) module from a local archive, + * deploying its FILES ONLY — no DB migrations (the shared DB was already + * migrated by MAIN). + * + * @param string $zipFilePath Path to the .zip archive fetched from MAIN. + * @return string Installed module name. + */ + public function deployFromArchiveFilesOnly(string $zipFilePath): string { + if (!is_file($zipFilePath)) { + throw new \InvalidArgumentException('Module archive not found.'); + } + + $tempBase = rtrim(sys_get_temp_dir(), '/') . '/xc_module_' . bin2hex(random_bytes(8)); + if (!@mkdir($tempBase, 0755, true) && !is_dir($tempBase)) { + throw new \RuntimeException('Unable to create temporary directory.'); + } + + try { + $this->extractArchive($zipFilePath, $tempBase); + + $moduleDir = $this->resolveExtractedModuleDir($tempBase); + $moduleName = $this->placeModuleFiles($moduleDir); + $targetDir = $this->modulePathFor($moduleName); + + // Keep the archive locally too, so this LB can re-seed if needed. + $manifest = $this->readModuleManifest($moduleName); + $version = (string) ($manifest['version'] ?? '0.0.0'); + $this->storeModuleArchive($zipFilePath, $moduleName, $version); + + $this->recordInstalledVersion($moduleName, $version); + $this->setState($moduleName, ModuleState::Enabled); + $this->hotReloadSafe($moduleName, $targetDir); + + return $moduleName; + } finally { + $this->deleteDirectory($tempBase); + } + } + + /** + * Copy a module archive into the local archives directory as + * {name}_{version}.zip (idempotent — overwrites any previous copy). + */ + private function storeModuleArchive(string $sourceZip, string $name, string $version): void { + $dest = $this->archivePathFor($name, $version); + $dir = dirname($dest); + if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { + throw new \RuntimeException('Unable to create modules archive directory.'); + } + if (realpath($sourceZip) !== realpath($dest) && !@copy($sourceZip, $dest)) { + throw new \RuntimeException('Unable to store module archive.'); + } + @chmod($dest, 0644); + } + + /** + * Read a module's manifest (module.json) from the modules directory. + * + * @return array Decoded manifest, or [] if missing/invalid. + */ + private function readModuleManifest(string $name): array { + $name = $this->sanitizeModuleName($name); + // Resolve the real {name}_{hash5} (or legacy bare) directory — reading the + // bare path would miss the manifest for a hash-suffixed module. + $file = $this->modulePathFor($name) . '/module.json'; + if (!is_file($file)) { + return []; + } + $data = json_decode((string) @file_get_contents($file), true); + return is_array($data) ? $data : []; + } + + /** + * The module's version as declared in its module.json, or null if absent. + * Authoritative source for the installed version (the module's getVersion() + * is hardcoded and may drift from the shipped manifest). + * + * @param string $name Module name / directory. + * @return string|null + */ + private function manifestVersion(string $name): ?string { + $v = $this->readModuleManifest($name)['version'] ?? null; + return (is_string($v) && $v !== '') ? $v : null; + } + + /** + * Tell every enabled load balancer to install a module, if the manifest + * targets the LB environment. Runs only on MAIN. + * + * Inserts one `signals` row per LB with custom_data: + * {"action":"install_module","source":"platform|local","name":"…","version":"…"} + * The LB's root signals daemon picks it up and runs `console.php module:install`. + * + * @param string $name Module name / slug. + * @param array $manifest Decoded module.json. + * @param string $source 'platform' (pull from store) or 'local' (pull archive from MAIN). + * @param string $version Module version. + */ + private function distributeToLoadBalancers(string $name, array $manifest, string $source, string $version): void { + // Only MAIN distributes. LB installs are file-only and never re-dispatch. + if ($this->isLoadBalancer()) { + return; + } + + $environment = strtolower((string) ($manifest['environment'] ?? 'main')); + if (!in_array($environment, ['lb', 'any'], true)) { + return; // module is MAIN-only — nothing to distribute + } + + $db = $this->getDb(); + if ($db === null) { + return; + } + + $payload = json_encode([ + 'action' => 'install_module', + 'source' => $source === 'platform' ? 'platform' : 'local', + 'name' => $name, + 'version' => $version, + ]); + + // LB servers are streaming servers (server_type = 0) that are not the + // main panel and are enabled. Collect ids first so the INSERT loop does + // not clobber the active result set. + $db->query('SELECT `id` FROM `servers` WHERE `server_type` = 0 AND `is_main` = 0 AND `enabled` = 1;'); + $rServerIDs = []; + foreach ($db->get_rows() as $rRow) { + $rServerIDs[] = intval($rRow['id']); + } + + foreach ($rServerIDs as $rServerID) { + $db->query( + 'INSERT INTO `signals`(`server_id`, `time`, `custom_data`) VALUES(?, ?, ?);', + $rServerID, + time(), + $payload + ); + } + } + + /** + * @return bool True when running on a load balancer (is_lb = 1 in config). + */ + private function isLoadBalancer(): bool { + if (class_exists(ConfigReader::class)) { + return (bool) ConfigReader::get('is_lb'); + } + if (defined('SERVER_TYPE')) { + // SERVER_TYPE is an external runtime constant (not define()d in-tree); + // use constant() so static analysis doesn't flag an undefined constant. + // Mirrors ModuleLoader::detectEnvironment(). + return constant('SERVER_TYPE') === 'lb'; + } + return false; + } + + /** + * Download a module from the SaaS platform and install it. + * + * Delegates the full download → key-unwrap → extract flow to the + * XC_VM C extension, then runs installModule() to register it. + * Fires PackageInstalledEvent and hot-reloads the module into the + * current ServiceContainer without requiring a PHP-FPM restart. + * + * @param string $slug Module slug as listed on the platform. + * @param string $version Exact version string (e.g. "1.2.0"), or '' for the latest. + * @param string|null $apiKey API key for the SaaS platform. + * @return void + * @throws \RuntimeException If the C extension is missing, download fails, or install fails. + */ + public function downloadFromPlatform(string $slug, string $version = '', ?string $apiKey = null): void { + $slug = $this->sanitizeModuleName($slug); + $targetDir = $this->modulesPath . '/' . $slug; + + // Snapshot current state so a failed (re)install rolls back cleanly. We + // MOVE the existing module aside (outside modulesPath so the loader never + // scans it) — this both gives a clean dir for the new extract and a + // restore point. installed_version is captured for the version record. + $prevVersion = $this->readOverrides()[$slug]['installed_version'] ?? null; + $backupDir = $this->backupModuleDir($slug, $targetDir); + + try { + $result = $this->pullFilesFromPlatform($slug, $version, $apiKey); + $modulePath = $result['path']; + $resolvedVersion = (string) ($result['version'] ?: $version); + + // Acquire the per-machine ionCube license BEFORE installModule(): if the + // platform encoded the module with --with-license, the loader rejects the + // encoded files at require-time unless a valid .lic is already present. + // No-op when the platform has licensing disabled. + $this->acquireModuleLicense($slug, $apiKey); + + // Record the version the PLATFORM served (authoritative for store + // installs); the module's module.json/getVersion() may lag behind. + $this->installModule($slug, $resolvedVersion); + + EventDispatcher::dispatch(new PackageInstalledEvent( + slug: $result['module'], + version: $resolvedVersion, + path: $modulePath, + installedAt: time(), + )); + + $this->hotReload($slug, $modulePath); + + // Mark as store-installed and remember the version the Rollback + // button should target. The previous version is authoritative from + // the PLATFORM (it holds release history); it may be null when the + // platform has no prior approved version. (NB: $prevVersion below is + // the LOCAL pre-install version, used only for failure rollback.) + $this->recordPlatformSource($slug, $result['previous_version'] ?? null); + + // If the manifest targets load balancers, tell every LB to pull the + // same module from the platform with its own install_id (MAIN-only). + $this->distributeToLoadBalancers($slug, $this->readModuleManifest($slug), 'platform', $resolvedVersion); + + // Success — drop the rollback snapshot. + if ($backupDir !== null) { + $this->deleteDirectory($backupDir); + } + } catch (\Throwable $e) { + $restored = $this->restoreModuleBackup($slug, $targetDir, $backupDir, $prevVersion); + throw new \RuntimeException( + "Platform install of '{$slug}' failed" + . ($restored + ? ' — rolled back to previous version' . ($prevVersion ? " {$prevVersion}" : '') + : '') + . ': ' . $e->getMessage(), + 0, + $e + ); + } + } + + /** + * Roll a store-installed module back to its previously installed version. + * + * Uses the `previous_version` recorded at the last store install (still + * available on the platform within its retention window). Re-installs that + * exact version, then clears `previous_version` (one-shot rollback). LBs are + * re-synced to the rolled-back version via the normal distribution path. + * + * @param string $slug Module slug. + * @param string|null $apiKey Platform API key. + * @throws \RuntimeException If the module was not installed from the store or + * has no recorded previous version. + */ + public function rollbackFromPlatform(string $slug, ?string $apiKey = null): void { + $slug = $this->sanitizeModuleName($slug); + $overrides = $this->readOverrides(); + $entry = $overrides[$slug] ?? []; + + if (($entry['source'] ?? '') !== 'platform') { + throw new \RuntimeException("Module '{$slug}' was not installed from the store; cannot roll back."); + } + $previous = $entry['previous_version'] ?? ''; + if ($previous === '') { + throw new \RuntimeException("No previous version recorded for '{$slug}'."); + } + + // Re-installs the previous version (explicit, not "latest"). + $this->downloadFromPlatform($slug, $previous, $apiKey); + + // One-shot: drop the previous-version marker so the rollback button hides + // until the next successful update creates a new restore point. + $this->clearPreviousVersion($slug); + } + + /** Mark a module as store-installed and (optionally) record the replaced version. */ + private function recordPlatformSource(string $name, ?string $previousVersion): void { + $overrides = $this->readOverrides(); + if (!isset($overrides[$name]) || !is_array($overrides[$name])) { + $overrides[$name] = []; + } + $overrides[$name]['source'] = 'platform'; + if ($previousVersion !== null && $previousVersion !== '') { + $overrides[$name]['previous_version'] = $previousVersion; + } + $this->writeOverrides($overrides); + } + + /** Record the install source for a module (e.g. 'platform' or 'local'). */ + private function recordModuleSource(string $name, string $source): void { + $overrides = $this->readOverrides(); + if (!isset($overrides[$name]) || !is_array($overrides[$name])) { + $overrides[$name] = []; + } + $overrides[$name]['source'] = $source; + $this->writeOverrides($overrides); + } + + /** + * Fetch and install the per-machine ionCube license for a module. + * + * Generates this machine's ionCube server-data, asks the platform to mint a + * hardware-bound + expiring .lic (XC_VM::module_license) and writes it next to + * the encoded files (the name the encoder's --with-license expects). + * + * Best-effort: when the platform has licensing disabled, the module is not + * entitled, or the loader/extension lacks the needed functions, it silently + * writes nothing — so unlicensed-encoded modules still install normally. + * + * @return bool True if a .lic was written. + */ + private function acquireModuleLicense(string $slug, ?string $apiKey): bool { + if (!class_exists('XC_VM') || !function_exists('ioncube_server_data')) { + return false; + } + + $serverData = @ioncube_server_data(); + if (!is_string($serverData) || $serverData === '') { + error_log("ModuleManager: license skipped for '{$slug}': ioncube_server_data() empty"); + return false; + } + + try { + $res = \XC_VM::module_license($slug, base64_encode($serverData), $apiKey ?? ''); + } catch (\Throwable $e) { + error_log("ModuleManager: license request failed for '{$slug}': " . $e->getMessage()); + return false; + } + + if (!is_array($res) || empty($res['ok'])) { + $reason = is_array($res) ? ($res['reason'] ?? 'unknown') : 'no_response'; + error_log("ModuleManager: license NOT issued for '{$slug}': {$reason}" + . (($apiKey ?? '') === '' ? ' (api_key пуст — для лицензии он обязателен)' : '')); + return false; + } + + $licName = basename((string) ($res['license_name'] ?? 'module.lic')); + $licBytes = base64_decode((string) ($res['license'] ?? ''), true); + if ($licName === '' || $licBytes === false || $licBytes === '') { + error_log("ModuleManager: license for '{$slug}' empty/undecodable in response"); + return false; + } + + $dir = $this->modulesPath . '/' . $slug; + if (!is_dir($dir)) { + error_log("ModuleManager: module dir missing for '{$slug}': {$dir}"); + return false; + } + + if (@file_put_contents($dir . '/' . $licName, $licBytes) === false) { + error_log("ModuleManager: failed to write license {$dir}/{$licName} (права?)"); + return false; + } + return true; + } + + /** + * Re-issue the per-machine license for an installed platform module — call + * before expiry, or after a "license expired/invalid" load failure. The SaaS + * refusing to mint (lapsed subscription / revoked) is the effective kill. + * + * @return bool True if a fresh .lic was written. + */ + public function renewModuleLicense(string $slug, ?string $apiKey = null): bool { + return $this->acquireModuleLicense($this->sanitizeModuleName($slug), $apiKey); + } + + /** Remove the recorded previous version for a module. */ + private function clearPreviousVersion(string $name): void { + $overrides = $this->readOverrides(); + if (isset($overrides[$name]['previous_version'])) { + unset($overrides[$name]['previous_version']); + $this->writeOverrides($overrides); + } + } + + /** + * Move an installed module directory to a backup location outside the + * modules path (so ModuleLoader never scans it). Returns the backup path, + * or null if the module was not installed. Falls back to copy+delete if the + * move (rename) fails. + */ + private function backupModuleDir(string $slug, string $targetDir): ?string { + if (!is_dir($targetDir)) { + return null; + } + $base = dirname($this->modulesPath) . '/.module_backups'; + if (!is_dir($base) && !@mkdir($base, 0755, true) && !is_dir($base)) { + // Cannot create a backup area — copy in place as a last resort is not + // possible; proceed without rollback rather than block the install. + return null; + } + $backupDir = $base . '/' . $slug . '_' . bin2hex(random_bytes(4)); + if (@rename($targetDir, $backupDir)) { + return $backupDir; + } + // rename failed (e.g. cross-device) — copy then remove the original. + $this->copyDirectory($targetDir, $backupDir); + $this->deleteDirectory($targetDir); + return $backupDir; + } + + /** + * Restore a module backup created by backupModuleDir() after a failed + * install, re-recording the previous installed version. Returns true if a + * backup was restored. + */ + private function restoreModuleBackup(string $slug, string $targetDir, ?string $backupDir, ?string $prevVersion): bool { + // Remove the (possibly partial) failed install first. + $realModules = realpath($this->modulesPath); + $realTarget = realpath($targetDir) ?: $targetDir; + if ($realModules && str_starts_with($realTarget, $realModules . '/')) { + $this->deleteDirectory($targetDir); + } + + if ($backupDir === null || !is_dir($backupDir)) { + return false; + } + + if (!@rename($backupDir, $targetDir)) { + $this->copyDirectory($backupDir, $targetDir); + $this->deleteDirectory($backupDir); + } + + if ($prevVersion !== null) { + $this->recordInstalledVersion($slug, $prevVersion); + } + $this->setState($slug, ModuleState::Enabled); + return true; + } + + /** + * LB-side: download a store module from the platform and deploy its FILES + * ONLY — no DB migrations. + * + * LB servers share MAIN's database, so the module's schema migrations have + * already been applied by MAIN. Here we only need the decrypted code on the + * LB so it loads under environment=lb/any. Each LB registers and downloads + * with its OWN install_id. + * + * @param string $slug Module slug on the platform. + * @param string $version Exact version string. + * @param string|null $apiKey Shared platform API key (from settings). + * @return void + */ + public function deployFromPlatformFilesOnly(string $slug, string $version, ?string $apiKey = null): void { + $result = $this->pullFilesFromPlatform($slug, $version, $apiKey); + + EventDispatcher::dispatch(new PackageInstalledEvent( + slug: $result['module'], + version: $result['version'], + path: $result['path'], + installedAt: time(), + )); + + $this->recordInstalledVersion($slug, (string) ($result['version'] ?: $version)); + $this->setState($slug, ModuleState::Enabled); + $this->hotReloadSafe($slug, $result['path']); + } + + /** + * Register the panel and pull (download + decrypt + extract) a module's + * files from the platform via the C extension. Does NOT run installModule(). + * + * @return array{ok: bool, module: string, version: string, path: string} + * @throws \RuntimeException On a missing extension, registration or download failure. + */ + private function pullFilesFromPlatform(string $slug, string $version, ?string $apiKey): array { + if (!class_exists('XC_VM')) { + throw new \RuntimeException('XC_VM extension is not loaded. Install xcvm_core.so and enable it in php.ini.'); + } + + // Ensure this panel is registered with the platform before installing. + // module_install() asks the SaaS to wrap the module key in an X25519 + // SealedBox for *this* panel's public key; if the panel was never + // registered the /plugins/key endpoint answers "panel_not_registered" + // and the install fails. Registration is an idempotent upsert keyed by + // install_id, so running it before every install also guarantees the + // server holds the public key matching our current local secret key. + $reg = \XC_VM::panel_register($apiKey ?? ''); + if (!is_array($reg) || empty($reg['ok'])) { + $regReason = $reg['message'] ?? ($reg['reason'] ?? 'unknown'); + throw new \RuntimeException("Panel registration with platform failed for module '{$slug}': {$regReason}"); + } + + $result = \XC_VM::module_install($slug, $version, $apiKey ?? ''); + + if (!is_array($result) || empty($result['ok'])) { + $reason = $result['error'] ?? 'unknown'; + throw new \RuntimeException("Platform download failed for module '{$slug}': {$reason}"); + } + + return [ + 'ok' => true, + 'module' => $result['module'] ?? $slug, + 'version' => $result['version'] ?? $version, + 'path' => $result['path'] ?? ($this->modulesPath . '/' . $slug), + // Previous approved version reported by the platform (for the + // Rollback button). May be absent/null when there is no prior version. + 'previous_version' => $result['previous_version'] ?? null, + ]; + } + + /** + * Hot-reload a newly installed module into the running ServiceContainer. + * + * Loads and boots the module within the current request so it becomes + * immediately usable without a PHP-FPM restart. + * + * @param string $slug Module name. + * @param string $modulePath Absolute path to the module directory. + */ + + /** + * Hot-reload only when serving a web request, never crash the caller. + * + * On an LB the install runs from a root CLI cron where there is no live + * request to keep warm and no router/container wired — the module simply + * loads from disk on the next request. So skip hot-reload under CLI and + * swallow any error. + */ + private function hotReloadSafe(string $slug, string $modulePath): void { + if (PHP_SAPI === 'cli') { + return; + } + try { + $this->hotReload($slug, $modulePath); + } catch (\Throwable $e) { + error_log("ModuleManager: hot-reload skipped for '{$slug}': " . $e->getMessage()); + } + } + + /** + * Hot-reload a freshly installed module without restarting PHP-FPM. + * + * Loads the module, boots it and registers its routes against the live + * container so it becomes usable within the current request lifecycle. + * + * @param string $slug Module slug. + * @param string $modulePath Filesystem path to the module. + * @return void + */ + private function hotReload(string $slug, string $modulePath): void { + $container = ServiceContainer::getInstance(); + + $loader = new ModuleLoader(); + if (!$loader->load($slug, $modulePath)) { + return; + } + + $router = $container->getOrDefault('router'); + $loader->bootAll($container, $router instanceof Router ? $router : null); + } + + /** + * Run migrations whose target version falls in (fromVersion, toVersion]. + * + * @param array $migrations + * @param string $fromVersion Currently installed version (exclusive lower bound). + * @param string $toVersion New version (inclusive upper bound). + */ + private function runPendingMigrations(array $migrations, string $fromVersion, string $toVersion): void { + $pending = []; + foreach ($migrations as $version => $callable) { + if ( + version_compare($version, $fromVersion, '>') && + version_compare($version, $toVersion, '<=') + ) { + $pending[$version] = $callable; + } + } + + uksort($pending, 'version_compare'); + + $db = $this->getDb(); + foreach ($pending as $callable) { + if ($db !== null && method_exists($db, 'transactional')) { + $db->transactional(fn() => $callable($this->container)); + } else { + $callable($this->container); + } + } + } + + /** + * Persist the installed version for a module in config/modules.php. + * + * @param string $name Module name. + * @param string $version Installed version string. + */ + private function recordInstalledVersion(string $name, string $version): void { + $overrides = $this->readOverrides(); + if (!isset($overrides[$name]) || !is_array($overrides[$name])) { + $overrides[$name] = []; + } + $overrides[$name]['installed_version'] = $version; + $this->writeOverrides($overrides); + } + + /** + * Remove the recorded installed version for a module from config/modules.php. + * + * @param string $name Module name. + */ + private function clearInstalledVersion(string $name): void { + $overrides = $this->readOverrides(); + if (!isset($overrides[$name]['installed_version'])) { + return; + } + unset($overrides[$name]['installed_version']); + if (empty($overrides[$name])) { + unset($overrides[$name]); + } + $this->writeOverrides($overrides); + } + + /** + * Record (or clear) the latest available version for a module in + * config/modules.php — written by the update-availability check + * (ModuleUpdatesCronJob) and read back by listModules()/the UI to show the + * Update button only when a newer version actually exists at the source. + * + * A null/empty version clears the flag (nothing newer, or not checkable). + * + * @param string $name Module name. + * @param string|null $version Latest available version, or null to clear. + * @return void + */ + public function recordAvailableVersion(string $name, ?string $version): void { + $name = $this->sanitizeModuleName($name); + $version = $version !== null ? trim($version) : ''; + + $overrides = $this->readOverrides(); + $current = (string) ($overrides[$name]['available_version'] ?? ''); + if ($current === $version) { + return; // no change — avoid a needless config rewrite + } + + if ($version === '') { + unset($overrides[$name]['available_version']); + if (isset($overrides[$name]) && empty($overrides[$name])) { + unset($overrides[$name]); + } + } else { + $overrides[$name]['available_version'] = $version; + } + $this->writeOverrides($overrides); + } + + /** + * Load and return a module instance by name. + * + * @param string $name Module name. + * @return object Module instance implementing ModuleInterface. + * @throws \RuntimeException If the module cannot be loaded or instantiated. + */ + private function loadModuleInstance(string $name) { + $name = $this->sanitizeModuleName($name); + $loader = new ModuleLoader(); + // Resolve the real directory ({name}_{hash5}, or a legacy bare {name}) — the + // module rarely lives at the bare path, so passing that would fail to find + // the class file for a freshly-uploaded module and abort its install. + $ok = $loader->load($name, $this->modulePathFor($name)); + if (!$ok) { + throw new ModuleNotFoundException('Cannot load module: ' . $name); + } + + $module = $loader->getModule($name); + if (!$module) { + throw new ModuleNotFoundException('Module instance is not available: ' . $name); + } + + return $module; + } + + /** + * Validate and sanitize a module name. + * + * @param string $name Raw module name. + * @return string Sanitized module name. + * @throws \InvalidArgumentException If the name is invalid. + */ + private function sanitizeModuleName(string $name): string { + $name = trim((string) $name); + if (!preg_match('/^[a-z0-9][a-z0-9\-]*$/', $name)) { + throw new ModuleException('Invalid module name.'); + } + return $name; + } + + /** + * Read module overrides from config/modules.php. + * + * @return array Module overrides keyed by module name. + */ + private function readOverrides(): array { + if (!file_exists($this->overridesPath)) { + return []; + } + + $data = require $this->overridesPath; + return is_array($data) ? $data : []; + } + + /** + * Write module overrides to config/modules.php atomically. + * + * Writes to a sibling temp file then renames into place, so concurrent + * requests can never read a partially-written file. + * + * @param array $overrides Module overrides to persist. + * @return void + * @throws \RuntimeException If the file cannot be written or renamed. + */ + private function writeOverrides(array $overrides): void { + ksort($overrides); + + $content = "overridesPath); + $tmp = @tempnam($dir, '.modules_tmp_'); + if ($tmp === false) { + throw new \RuntimeException('Unable to create temporary file for config/modules.php'); + } + + try { + if (@file_put_contents($tmp, $content, LOCK_EX) === false) { + throw new \RuntimeException('Unable to write config/modules.php (temp stage)'); + } + + @chmod($tmp, 0644); + + if (!@rename($tmp, $this->overridesPath)) { + throw new \RuntimeException('Unable to atomically replace config/modules.php'); + } + + // config/modules.php is read back via require(), which OPcache caches + // (validate_timestamps + revalidate_freq). Install does several + // read-modify-write cycles in one request (setState → recordInstalled + // → recordSource); without invalidation the later reads see a STALE + // array and the final write clobbers the module's state, leaving it + // not-Enabled until a manual disable/enable. Drop the cached entry so + // every readOverrides() in this request sees what we just wrote. + if (function_exists('opcache_invalidate')) { + opcache_invalidate($this->overridesPath, true); + } + } catch (\Throwable $e) { + @unlink($tmp); + throw $e; + } + } + + /** + * Extract a module archive (.zip or .tar.gz/.tgz/.tar) into $destination. + * + * The type is detected by magic bytes (uploads arrive as an extension-less tmp + * file), then routed: + * - tar.gz : PharData (bundled with PHP, no extension) → fallback to `tar` CLI + * - zip : ZipArchive (validated) → fallback to the `unzip` CLI + * + * $destination is always an isolated temp dir created by the caller, and the + * CLI tools refuse to write outside it (they strip `../`/absolute members), so + * extraction stays contained even without the per-entry PHP validation. + * + * @throws \RuntimeException If the archive cannot be extracted in this environment. + */ + private function extractArchive(string $archivePath, string $destination): void { + if (!is_dir($destination) && !@mkdir($destination, 0755, true) && !is_dir($destination)) { + throw new \RuntimeException('Unable to create extraction directory.'); + } + + if ($this->looksLikeTar($archivePath)) { + $this->extractTarArchive($archivePath, $destination); + return; + } + + // ZIP: prefer the validated PHP extension, else the `unzip` CLI. + if (class_exists('ZipArchive')) { + $this->extractZipViaZipArchive($archivePath, $destination); + return; + } + if ($this->hasBinary('unzip')) { + // unzip exit 1 = success with warnings (e.g. skipped unsafe paths). + $this->runExtractor('unzip -oqq ' . escapeshellarg($archivePath) . ' -d ' . escapeshellarg($destination), 1); + return; + } + throw new \RuntimeException('Cannot extract .zip: install the PHP zip extension or the `unzip` command (or upload a .tar.gz).'); + } + + /** Detect a tar/tar.gz archive by extension, or gzip magic bytes for tmp uploads. */ + private function looksLikeTar(string $path): bool { + $lower = strtolower($path); + if (str_ends_with($lower, '.tar.gz') || str_ends_with($lower, '.tgz') || str_ends_with($lower, '.tar')) { + return true; + } + if (str_ends_with($lower, '.zip')) { + return false; + } + // Extension-less (uploaded tmp file): sniff magic. gzip = 1f 8b. + $fh = @fopen($path, 'rb'); + if ($fh === false) { + return false; + } + $magic = fread($fh, 3); + fclose($fh); + return strlen($magic) >= 2 && ord($magic[0]) === 0x1f && ord($magic[1]) === 0x8b; + } + + /** Extract .tar/.tar.gz via PharData (no PHP extension needed) or the `tar` CLI. */ + private function extractTarArchive(string $archivePath, string $destination): void { + if (class_exists('PharData')) { + try { + (new \PharData($archivePath))->extractTo($destination, null, true); + return; + } catch (\Throwable $e) { + // fall through to the CLI + } + } + if ($this->hasBinary('tar')) { + // GNU tar auto-detects gzip and strips unsafe (../, absolute) members. + $this->runExtractor('tar -xf ' . escapeshellarg($archivePath) . ' -C ' . escapeshellarg($destination), 0); + return; + } + throw new \RuntimeException('Cannot extract .tar.gz: PharData is unavailable and the `tar` command is missing.'); + } + + /** True if $bin resolves on PATH. */ + private function hasBinary(string $bin): bool { + $out = @shell_exec('command -v ' . escapeshellarg($bin) . ' 2>/dev/null'); + return is_string($out) && trim($out) !== ''; + } + + /** Run a CLI extractor; treat exit codes above $maxOkCode as failures. */ + private function runExtractor(string $cmd, int $maxOkCode): void { + $out = []; + $code = 0; + @exec($cmd . ' 2>&1', $out, $code); + if ($code > $maxOkCode) { + throw new \RuntimeException('Archive extraction failed (exit ' . $code . '): ' . implode(' ', array_slice($out, -3))); + } + } + + /** + * Safely extract a ZIP archive via the PHP zip extension. + * + * Validates each entry for path traversal attacks before extracting. + * + * @param string $zipFilePath Path to the zip file. + * @param string $destination Extraction target directory. + * @return void + * @throws \RuntimeException If extraction fails or unsafe entries are detected. + */ + private function extractZipViaZipArchive(string $zipFilePath, string $destination): void { + $zip = new \ZipArchive(); + if ($zip->open($zipFilePath) !== true) { + throw new \RuntimeException('Unable to open zip archive.'); + } + + try { + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + if ($entry === false || $entry === '') { + continue; + } + + $entry = str_replace('\\', '/', $entry); + if (strpos($entry, '../') !== false || strpos($entry, '..\\') !== false || strpos($entry, ':') !== false) { + throw new \RuntimeException('Unsafe zip entry detected.'); + } + + $targetPath = rtrim($destination, '/') . '/' . ltrim($entry, '/'); + + if (substr($entry, -1) === '/') { + if (!is_dir($targetPath) && !@mkdir($targetPath, 0755, true)) { + throw new \RuntimeException('Unable to create directory while extracting zip.'); + } + continue; + } + + $dir = dirname($targetPath); + if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { + throw new \RuntimeException('Unable to create directory while extracting zip.'); + } + + $in = $zip->getStream($entry); + if (!$in) { + throw new \RuntimeException('Unable to read zip entry stream.'); + } + + $out = @fopen($targetPath, 'wb'); + if (!$out) { + fclose($in); + throw new \RuntimeException('Unable to write extracted file.'); + } + + while (!feof($in)) { + $chunk = fread($in, 8192); + if ($chunk === false) { + break; + } + fwrite($out, $chunk); + } + + fclose($in); + fclose($out); + } + } finally { + $zip->close(); + } + } + + /** + * Resolve the module root directory from the extracted temp path. + * + * Handles both flat and nested zip layouts. + * + * @param string $tempBase Temporary extraction directory. + * @return string Path to the directory containing module.json. + * @throws \RuntimeException If module.json is not found or ambiguous. + */ + private function resolveExtractedModuleDir(string $tempBase): string { + $rootJson = $tempBase . '/module.json'; + if (is_file($rootJson)) { + return $tempBase; + } + + $jsonFiles = glob($tempBase . '/*/module.json') ?: []; + if (count($jsonFiles) !== 1) { + throw new \RuntimeException('Archive must contain exactly one module with module.json.'); + } + + return dirname($jsonFiles[0]); + } + + /** + * The module's canonical name from its module.json ("name"), falling back to + * the directory basename. The manifest is authoritative: extracting a flat + * archive (module.json at the root) or a differently-named wrapper dir would + * otherwise yield the random temp-dir name (e.g. "xc_module_ab12"), which fails + * sanitizeModuleName() with "Invalid module name." + */ + private function manifestNameFromDir(string $moduleDir): string { + $meta = json_decode((string) @file_get_contents($moduleDir . '/module.json'), true); + $name = is_array($meta) ? trim((string) ($meta['name'] ?? '')) : ''; + return $name !== '' ? $name : basename($moduleDir); + } + + /** + * Recursively delete a directory and its contents. + * + * @param string $path Path to delete. + * @return void + */ + private function deleteDirectory(string $path): void { + if (!file_exists($path)) { + return; + } + + if (is_file($path) || is_link($path)) { + @unlink($path); + return; + } + + $items = scandir($path); + if (!$items) { + @rmdir($path); + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $this->deleteDirectory($path . '/' . $item); + } + + @rmdir($path); + } + + /** + * Recursively copy a directory. + * + * @param string $source Source directory path. + * @param string $destination Destination directory path. + * @return void + * @throws \RuntimeException If copying fails. + */ + private function copyDirectory(string $source, string $destination): void { + if (!is_dir($source)) { + throw new \RuntimeException('Source directory not found: ' . $source); + } + + if (!is_dir($destination) && !@mkdir($destination, 0755, true)) { + throw new \RuntimeException('Unable to create module directory.'); + } + + $items = scandir($source); + if (!$items) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $src = $source . '/' . $item; + $dst = $destination . '/' . $item; + + if (is_dir($src)) { + $this->copyDirectory($src, $dst); + } else { + if (!@copy($src, $dst)) { + throw new \RuntimeException('Unable to copy file: ' . $item); + } + } + } + } } diff --git a/src/Core/Module/ModuleMigrator.php b/src/Core/Module/ModuleMigrator.php index 1557b7a3..02dc3821 100644 --- a/src/Core/Module/ModuleMigrator.php +++ b/src/Core/Module/ModuleMigrator.php @@ -36,136 +36,135 @@ namespace XcVm\Core\Module; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class ModuleMigrator { + /** + * Apply the module's master schema on a fresh install. + * + * Runs `database.sql` (the full current schema) when present. A module that + * ships no master but only version deltas falls back to replaying every + * `migrations/.sql` up to `$to` — this keeps delta-only modules + * installable. + * + * @param string $modulePath Absolute path of the module directory. + * @param object $db Database handler (query() API). + * @param string $to Target version (used only by the delta fallback). + * @return string[] What ran: `['database.sql']`, or the replayed delta versions. + */ + public static function install(string $modulePath, object $db, string $to): array { + $master = $modulePath . '/database.sql'; + if (is_file($master)) { + self::runFile($db, $master); + return ['database.sql']; + } + // No master schema — replay forward deltas up to the target version. + return self::up($modulePath, $db, null, $to); + } - /** - * Apply the module's master schema on a fresh install. - * - * Runs `database.sql` (the full current schema) when present. A module that - * ships no master but only version deltas falls back to replaying every - * `migrations/.sql` up to `$to` — this keeps delta-only modules - * installable. - * - * @param string $modulePath Absolute path of the module directory. - * @param object $db Database handler (query() API). - * @param string $to Target version (used only by the delta fallback). - * @return string[] What ran: `['database.sql']`, or the replayed delta versions. - */ - public static function install(string $modulePath, object $db, string $to): array { - $master = $modulePath . '/database.sql'; - if (is_file($master)) { - self::runFile($db, $master); - return ['database.sql']; - } - // No master schema — replay forward deltas up to the target version. - return self::up($modulePath, $db, null, $to); - } + /** + * Tear the module's schema down on uninstall. + * + * Runs the single `database_drop.sql` when present. No-op if the module ships + * no teardown file. + * + * @param string $modulePath Absolute path of the module directory. + * @param object $db Database handler. + * @return string[] `['database_drop.sql']` if it ran, otherwise `[]`. + */ + public static function uninstall(string $modulePath, object $db): array { + $drop = $modulePath . '/database_drop.sql'; + if (is_file($drop)) { + self::runFile($db, $drop); + return ['database_drop.sql']; + } + return []; + } - /** - * Tear the module's schema down on uninstall. - * - * Runs the single `database_drop.sql` when present. No-op if the module ships - * no teardown file. - * - * @param string $modulePath Absolute path of the module directory. - * @param object $db Database handler. - * @return string[] `['database_drop.sql']` if it ran, otherwise `[]`. - */ - public static function uninstall(string $modulePath, object $db): array { - $drop = $modulePath . '/database_drop.sql'; - if (is_file($drop)) { - self::runFile($db, $drop); - return ['database_drop.sql']; - } - return []; - } + /** + * Apply forward version deltas for versions in the (`$from`, `$to`] range. + * + * Used on update to bring an already-installed panel up to the current + * schema. Deltas are forward-only — teardown is handled by database_drop.sql. + * + * @param string $modulePath Absolute path of the module directory. + * @param object $db Database handler. + * @param string|null $from Already-applied version, or null to run all ≤ $to. + * @param string $to Target version (inclusive). + * @return string[] Versions whose delta ran, in apply order. + */ + public static function up(string $modulePath, object $db, ?string $from, string $to): array { + $applied = []; + foreach (self::discover($modulePath) as [$version, $file]) { + if ($from !== null && version_compare($version, $from, '<=')) { + continue; + } + if (version_compare($version, $to, '>')) { + continue; + } + self::runFile($db, $file); + $applied[] = $version; + } + return $applied; + } - /** - * Apply forward version deltas for versions in the (`$from`, `$to`] range. - * - * Used on update to bring an already-installed panel up to the current - * schema. Deltas are forward-only — teardown is handled by database_drop.sql. - * - * @param string $modulePath Absolute path of the module directory. - * @param object $db Database handler. - * @param string|null $from Already-applied version, or null to run all ≤ $to. - * @param string $to Target version (inclusive). - * @return string[] Versions whose delta ran, in apply order. - */ - public static function up(string $modulePath, object $db, ?string $from, string $to): array { - $applied = []; - foreach (self::discover($modulePath) as [$version, $file]) { - if ($from !== null && version_compare($version, $from, '<=')) { - continue; - } - if (version_compare($version, $to, '>')) { - continue; - } - self::runFile($db, $file); - $applied[] = $version; - } - return $applied; - } + /** + * Whether the module ships any schema files at all (master, teardown, or deltas). + */ + public static function has(string $modulePath): bool { + return is_file($modulePath . '/database.sql') + || is_file($modulePath . '/database_drop.sql') + || !empty(self::discover($modulePath)); + } - /** - * Whether the module ships any schema files at all (master, teardown, or deltas). - */ - public static function has(string $modulePath): bool { - return is_file($modulePath . '/database.sql') - || is_file($modulePath . '/database_drop.sql') - || !empty(self::discover($modulePath)); - } + /** + * Discover forward delta files, sorted ascending by version. + * + * Files are `migrations/.sql` (forward-only; no `.up`/`.down` suffix — + * teardown lives in database_drop.sql). Anything not matching dotted-numeric + * semver is ignored. + * + * @return array [version, absolute path] + */ + private static function discover(string $modulePath): array { + $dir = $modulePath . '/migrations'; + if (!is_dir($dir)) { + return []; + } - /** - * Discover forward delta files, sorted ascending by version. - * - * Files are `migrations/.sql` (forward-only; no `.up`/`.down` suffix — - * teardown lives in database_drop.sql). Anything not matching dotted-numeric - * semver is ignored. - * - * @return array [version, absolute path] - */ - private static function discover(string $modulePath): array { - $dir = $modulePath . '/migrations'; - if (!is_dir($dir)) { - return []; - } + $out = []; + foreach (glob($dir . '/*.sql') ?: [] as $file) { + $version = basename($file, '.sql'); + // Accept dotted numeric semver only (e.g. 1.0.0, 2.1); ignore anything else. + if (preg_match('/^\d+(?:\.\d+)*$/', $version)) { + $out[] = [$version, $file]; + } + } + usort($out, static fn($a, $b) => version_compare($a[0], $b[0])); // ascending + return $out; + } - $out = []; - foreach (glob($dir . '/*.sql') ?: [] as $file) { - $version = basename($file, '.sql'); - // Accept dotted numeric semver only (e.g. 1.0.0, 2.1); ignore anything else. - if (preg_match('/^\d+(?:\.\d+)*$/', $version)) { - $out[] = [$version, $file]; - } - } - usort($out, static fn($a, $b) => version_compare($a[0], $b[0])); // ascending - return $out; - } + /** + * Execute every statement in a SQL file. Throws on the first failure. + */ + private static function runFile(object $db, string $file): void { + $sql = trim((string) file_get_contents($file)); + if ($sql === '') { + return; + } - /** - * Execute every statement in a SQL file. Throws on the first failure. - */ - private static function runFile(object $db, string $file): void { - $sql = trim((string) file_get_contents($file)); - if ($sql === '') { - return; - } - - foreach (array_filter(array_map('trim', explode(';', $sql))) as $statement) { - // Drop comment-only lines so the residual statement is real SQL. - $lines = array_filter( - explode("\n", $statement), - static fn($line) => strpos(ltrim($line), '--') !== 0 - ); - $statement = trim(implode("\n", $lines)); - if ($statement === '') { - continue; - } - if (!$db->query($statement . ';')) { - throw new \RuntimeException( - 'Module migration failed in ' . basename($file) . ': ' . $statement - ); - } - } - } + foreach (array_filter(array_map('trim', explode(';', $sql))) as $statement) { + // Drop comment-only lines so the residual statement is real SQL. + $lines = array_filter( + explode("\n", $statement), + static fn($line) => strpos(ltrim($line), '--') !== 0 + ); + $statement = trim(implode("\n", $lines)); + if ($statement === '') { + continue; + } + if (!$db->query($statement . ';')) { + throw new \RuntimeException( + 'Module migration failed in ' . basename($file) . ': ' . $statement + ); + } + } + } } diff --git a/src/Core/Module/ModuleUpdateChecker.php b/src/Core/Module/ModuleUpdateChecker.php index e1871c17..fc86d27e 100644 --- a/src/Core/Module/ModuleUpdateChecker.php +++ b/src/Core/Module/ModuleUpdateChecker.php @@ -26,102 +26,101 @@ use XcVm\Core\Updates\GitHubReleases; * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class ModuleUpdateChecker { + /** Why the last latestAvailable() call could not check its source (null = checked fine). */ + private ?string $lastError = null; - /** Why the last latestAvailable() call could not check its source (null = checked fine). */ - private ?string $lastError = null; + /** + * Error message from the most recent latestAvailable() call, or null when the + * source was checked successfully. Lets callers distinguish "no update" from + * "could not check" (both return null from latestAvailable()). + */ + public function lastError(): ?string { + return $this->lastError; + } - /** - * Error message from the most recent latestAvailable() call, or null when the - * source was checked successfully. Lets callers distinguish "no update" from - * "could not check" (both return null from latestAvailable()). - */ - public function lastError(): ?string { - return $this->lastError; - } + /** + * Latest available version for a module (a listModules() row), or null. + * + * @param array $module Module row with keys `update`, `version`, `installed_version`. + * @return string|null Version string, or null if nothing newer / not checkable. + */ + public function latestAvailable(array $module): ?string { + $this->lastError = null; - /** - * Latest available version for a module (a listModules() row), or null. - * - * @param array $module Module row with keys `update`, `version`, `installed_version`. - * @return string|null Version string, or null if nothing newer / not checkable. - */ - public function latestAvailable(array $module): ?string { - $this->lastError = null; + $update = is_array($module['update'] ?? null) ? $module['update'] : []; + $source = (string) ($update['source'] ?? 'bundled'); + $installed = (string) ($module['installed_version'] ?? ''); - $update = is_array($module['update'] ?? null) ? $module['update'] : []; - $source = (string) ($update['source'] ?? 'bundled'); - $installed = (string) ($module['installed_version'] ?? ''); + return match ($source) { + 'git' => $this->fromGit($update, $installed), + 'url' => $this->fromUrl($update), + 'platform' => $this->fromPlatform($update), + default => ((string) ($module['version'] ?? '')) ?: null, // bundled + }; + } - return match ($source) { - 'git' => $this->fromGit($update, $installed), - 'url' => $this->fromUrl($update), - 'platform' => $this->fromPlatform($update), - default => ((string) ($module['version'] ?? '')) ?: null, // bundled - }; - } + /** GitHub releases of update.repository, newest newer-than-installed (or null). */ + private function fromGit(array $update, string $installed): ?string { + $repo = (string) ($update['repository'] ?? ''); + // https://github.com/OWNER/REPO(.git) | git@github.com:OWNER/REPO.git + if (!preg_match('~github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$~i', $repo, $m)) { + $this->lastError = 'update.repository is not a GitHub URL: ' . ($repo !== '' ? $repo : '(empty)'); + return null; + } + // Map the manifest channel onto GitHubReleases' stable/beta. + $channel = in_array((string) ($update['channel'] ?? 'stable'), ['beta', 'unstable'], true) + ? 'beta' + : 'stable'; + try { + $gh = new GitHubReleases($m[1], $m[2], $channel); + return $gh->getLatestVersion($installed !== '' ? $installed : '0.0.0'); + } catch (\Throwable $e) { + $this->lastError = $e->getMessage(); + error_log('ModuleUpdateChecker(git): ' . $e->getMessage()); + return null; + } + } - /** GitHub releases of update.repository, newest newer-than-installed (or null). */ - private function fromGit(array $update, string $installed): ?string { - $repo = (string) ($update['repository'] ?? ''); - // https://github.com/OWNER/REPO(.git) | git@github.com:OWNER/REPO.git - if (!preg_match('~github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$~i', $repo, $m)) { - $this->lastError = 'update.repository is not a GitHub URL: ' . ($repo !== '' ? $repo : '(empty)'); - return null; - } - // Map the manifest channel onto GitHubReleases' stable/beta. - $channel = in_array((string) ($update['channel'] ?? 'stable'), ['beta', 'unstable'], true) - ? 'beta' - : 'stable'; - try { - $gh = new GitHubReleases($m[1], $m[2], $channel); - return $gh->getLatestVersion($installed !== '' ? $installed : '0.0.0'); - } catch (\Throwable $e) { - $this->lastError = $e->getMessage(); - error_log('ModuleUpdateChecker(git): ' . $e->getMessage()); - return null; - } - } + /** version.json at a self-hosted https URL: {"version":"1.2.3"}. */ + private function fromUrl(array $update): ?string { + $url = (string) ($update['url'] ?? ''); + if ($url === '' || stripos($url, 'https://') !== 0) { + $this->lastError = 'update.url must be an https:// URL'; // https only — SSRF/downgrade guard + return null; + } + $data = json_decode($this->httpGet($url), true); + $ver = is_array($data) ? trim((string) ($data['version'] ?? '')) : ''; + if ($ver === '') { + $this->lastError = 'no valid version.json at ' . $url; + return null; + } + return $ver; + } - /** version.json at a self-hosted https URL: {"version":"1.2.3"}. */ - private function fromUrl(array $update): ?string { - $url = (string) ($update['url'] ?? ''); - if ($url === '' || stripos($url, 'https://') !== 0) { - $this->lastError = 'update.url must be an https:// URL'; // https only — SSRF/downgrade guard - return null; - } - $data = json_decode($this->httpGet($url), true); - $ver = is_array($data) ? trim((string) ($data['version'] ?? '')) : ''; - if ($ver === '') { - $this->lastError = 'no valid version.json at ' . $url; - return null; - } - return $ver; - } + /** SaaS store latest version — best-effort; skipped if the extension has no such API. */ + private function fromPlatform(array $update): ?string { + if (!class_exists('XC_VM') || !method_exists('XC_VM', 'module_latest')) { + return null; // store resolves "latest approved" at install time + } + try { + $r = \XC_VM::module_latest((string) ($update['slug'] ?? '')); + return is_array($r) && !empty($r['version']) ? (string) $r['version'] : null; + } catch (\Throwable $e) { + $this->lastError = $e->getMessage(); + return null; + } + } - /** SaaS store latest version — best-effort; skipped if the extension has no such API. */ - private function fromPlatform(array $update): ?string { - if (!class_exists('XC_VM') || !method_exists('XC_VM', 'module_latest')) { - return null; // store resolves "latest approved" at install time - } - try { - $r = \XC_VM::module_latest((string) ($update['slug'] ?? '')); - return is_array($r) && !empty($r['version']) ? (string) $r['version'] : null; - } catch (\Throwable $e) { - $this->lastError = $e->getMessage(); - return null; - } - } - - /** cURL GET (file_get_contents over https does not work under PHP-FPM here). */ - private function httpGet(string $url): string { - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15); - curl_setopt($ch, CURLOPT_TIMEOUT, 30); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_USERAGENT, 'XC_VM-ModuleUpdateChecker'); - $body = curl_exec($ch); - curl_close($ch); - return is_string($body) ? $body : ''; - } + /** cURL GET (file_get_contents over https does not work under PHP-FPM here). */ + private function httpGet(string $url): string { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'XC_VM-ModuleUpdateChecker'); + $body = curl_exec($ch); + curl_close($ch); + return is_string($body) ? $body : ''; + } } diff --git a/src/Core/Module/NavbarItem.php b/src/Core/Module/NavbarItem.php index deb9db53..8553e157 100644 --- a/src/Core/Module/NavbarItem.php +++ b/src/Core/Module/NavbarItem.php @@ -24,181 +24,192 @@ namespace XcVm\Core\Module; */ class NavbarItem { + /** @var string Unique dot-separated key, e.g. 'management.service_setup.watch' */ + public $key; - /** @var string Unique dot-separated key, e.g. 'management.service_setup.watch' */ - public $key; - /** @var string|null Parent key, null for top-level items */ - public $parent = null; - /** @var string Target URL or '#' for group headers */ - public $url = '#'; - /** @var string Translation key passed to $language::get() */ - public $translationKey = ''; - /** @var string Fallback text used when translationKey is empty */ - public $fallbackTitle = ''; - /** @var string[] Permission names; OR-checked; empty means no permission gate */ - public $permissions = []; - /** @var int Sort order within the same parent level */ - public $order = 100; - /** @var string CSS class(es) for the icon element, e.g. 'fas fa-server' */ - public $icon = ''; - /** @var bool When true, item is hidden on mobile */ - public $desktopOnly = false; - /** @var bool When true, submenu is not rendered on mobile */ - public $noMobileSubmenu = false; - /** @var string Extra CSS class on the