From 5b35907388bc29bcb8726edaa0127d6a9a6df5dc Mon Sep 17 00:00:00 2001 From: Divarion_D Date: Wed, 9 Sep 2026 22:32:37 +0300 Subject: [PATCH] refactor(admin): readable, testable stream entry points (+2 bug fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean up the admin stream loopback scripts (api/live/proxy_api/thumb/ timeshift/vod) for readability and to give their core logic unit-test coverage, without changing behavior — except the two bugs called out below. Readability: - Invert the pervasive empty `if (c) {} else { body }` into `if (!c) { body }`. - Flatten deep if/else nests into guard-clause / elseif chains (live, vod). - Drop dead blank lines. Testability (pure logic extracted, then covered): - New value object XcVm\Domain\Stream\AdminStreamToken (decode + isValid) replaces the uitoken decrypt+expiry+IP block duplicated across live/thumb/ timeshift/vod; it reuses the existing-but-unadopted NetworkUtils::ipMatches(). Token start/duration are kept raw (a timeshift date string must not be cast). - Four pure helpers folded into the existing StreamUtils (not new one-method classes): sanitizeSegmentName, containerMimeType, timeshiftStartTimestamp, segmentRetryBudget — replacing inline path sanitization, a 44-line MIME switch, the timeshift date parser, and the retry-budget math. - Tests: AdminStreamTokenTest, NetworkUtilsTest (ipMatches had no coverage), and StreamUtilsTest extended for the four helpers. OPENSSL_EXTRA is defined in tests/bootstrap.php so token round-trips work. Bug fixes (behavior changes): - live: the retry budget used `$rTotalFails < intval(...) ?: 20`, which by operator precedence parsed as `(... < ...) ?: 20` — always truthy — so the `seg_time * 2` floor was ignored and the budget was always the configured wait. Now max(seg_time*2, wait ?: 20) via StreamUtils::segmentRetryBudget(). - proxy_api: the per-second byte-rate divided by `time() - last`, which is 0 for two samples in the same second (division by zero). Clamp to >= 1s. --- src/Core/Util/StreamUtils.php | 71 ++++++++++++++++++ src/Domain/Stream/AdminStreamToken.php | 92 ++++++++++++++++++++++++ src/Public/admin/api.php | 9 +-- src/Public/admin/live.php | 99 +++++++++----------------- src/Public/admin/proxy_api.php | 16 ++--- src/Public/admin/thumb.php | 17 ++--- src/Public/admin/timeshift.php | 57 +++++---------- src/Public/admin/vod.php | 89 +++++------------------ tests/Unit/AdminStreamTokenTest.php | 73 +++++++++++++++++++ tests/Unit/NetworkUtilsTest.php | 25 +++++++ tests/Unit/StreamUtilsTest.php | 27 +++++++ tests/bootstrap.php | 5 ++ 12 files changed, 378 insertions(+), 202 deletions(-) create mode 100644 src/Domain/Stream/AdminStreamToken.php create mode 100644 tests/Unit/AdminStreamTokenTest.php create mode 100644 tests/Unit/NetworkUtilsTest.php diff --git a/src/Core/Util/StreamUtils.php b/src/Core/Util/StreamUtils.php index 4c432d6b..5dea3fb1 100644 --- a/src/Core/Util/StreamUtils.php +++ b/src/Core/Util/StreamUtils.php @@ -362,4 +362,75 @@ class StreamUtils { return false; } + /** + * Strip path separators from a URL-encoded, user-supplied segment filename + * so it cannot traverse out of its directory. URL-decodes first, then + * removes every `/` and `\`. + * + * @param string $rRawSegment Raw (URL-encoded) segment from the request. + * @return string + */ + public static function sanitizeSegmentName($rRawSegment) { + return str_replace(array('\\', '/'), '', urldecode((string) $rRawSegment)); + } + + /** + * MIME type for a VOD container extension, or `application/octet-stream` + * when unknown. + * + * @param string $rContainer Container extension (e.g. "mp4"). + * @return string + */ + public static function containerMimeType($rContainer) { + $rMap = array( + 'mp4' => 'video/mp4', + 'mkv' => 'video/x-matroska', + 'avi' => 'video/x-msvideo', + '3gp' => 'video/3gpp', + 'flv' => 'video/x-flv', + 'wmv' => 'video/x-ms-wmv', + 'mov' => 'video/quicktime', + 'ts' => 'video/mp2t', + ); + + return $rMap[(string) $rContainer] ?? 'application/octet-stream'; + } + + /** + * Parse the timeshift `start` parameter into a unix timestamp. Accepts a raw + * timestamp, `YYYYMMDD-H`, or `Y-m-d:H-i`. + * + * @param string|int $rStartDate + * @return int + */ + public static function timeshiftStartTimestamp($rStartDate) { + if (is_numeric($rStartDate)) { + return (int) $rStartDate; + } + + if (substr_count((string) $rStartDate, '-') == 1) { + list($rDate, $rHour) = explode('-', (string) $rStartDate); + + return (int) mktime((int) $rHour, 0, 0, (int) substr($rDate, 4, 2), (int) substr($rDate, 6, 2), (int) substr($rDate, 0, 4)); + } + + list($rDate, $rTime) = explode(':', (string) $rStartDate); + list($rYear, $rMonth, $rDay) = explode('-', $rDate); + list($rHour, $rMinutes) = explode('-', $rTime); + + return (int) mktime((int) $rHour, (int) $rMinutes, 0, (int) $rMonth, (int) $rDay, (int) $rYear); + } + + /** + * Retry budget (seconds) the live loopback waits for the next HLS segment: + * the larger of twice the segment duration or the configured wait (0 → 20). + * + * @param int $rSegTimeSeconds Segment duration in seconds. + * @param int $rConfiguredWaitSeconds Configured `segment_wait_time`. + * @return int + */ + public static function segmentRetryBudget($rSegTimeSeconds, $rConfiguredWaitSeconds) { + return max((int) $rSegTimeSeconds * 2, (int) $rConfiguredWaitSeconds ?: 20); + } + } diff --git a/src/Domain/Stream/AdminStreamToken.php b/src/Domain/Stream/AdminStreamToken.php new file mode 100644 index 00000000..3e58e75c --- /dev/null +++ b/src/Domain/Stream/AdminStreamToken.php @@ -0,0 +1,92 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +final class AdminStreamToken { + + /** + * @param int $streamId Target stream id. + * @param string $ip Client IP the token was issued to. + * @param int $expires Expiry as a unix timestamp. + * @param string|null $container VOD container (vod.php only), else null. + * @param mixed $start Timeshift start (raw — may be a date string), else null. + * @param mixed $duration Timeshift duration (raw), else null. + */ + private function __construct( + public readonly int $streamId, + public readonly string $ip, + public readonly int $expires, + public readonly ?string $container = null, + public readonly mixed $start = null, + public readonly mixed $duration = null, + ) { + } + + /** + * Decrypt + decode a raw uitoken. Returns null on any decrypt/JSON failure + * or a missing required field — callers treat null exactly like an invalid + * token (404), matching the previous inline code where a missing field + * failed the expiry/IP check and fell through to the same 404. + * + * `start`/`duration` are kept raw (NOT cast) because timeshift accepts a + * non-numeric date string there. + * + * @param string $rawToken Encrypted token from the request. + * @param string $key Decryption key (live_streaming_pass). + * @return self|null + */ + public static function decode(string $rawToken, string $key): ?self { + $rDecrypted = Encryption::decrypt($rawToken, $key, OPENSSL_EXTRA); + + if (!is_string($rDecrypted)) { + return null; + } + + $rData = json_decode($rDecrypted, true); + + if (!is_array($rData) || !isset($rData['stream_id'], $rData['ip'], $rData['expires'])) { + return null; + } + + return new self( + (int) $rData['stream_id'], + (string) $rData['ip'], + (int) $rData['expires'], + isset($rData['container']) ? (string) $rData['container'] : null, + $rData['start'] ?? null, + $rData['duration'] ?? null, + ); + } + + /** + * Not expired and the caller IP matches (subnet-aware per + * {@see NetworkUtils::ipMatches()}). + * + * @param bool $rSubnetMatch Compare only the leading three octets. + * @param string|null $rClientIp Current client IP. + * @param int|null $rNow Reference time (defaults to time()). + * @return bool + */ + public function isValid(bool $rSubnetMatch, ?string $rClientIp, ?int $rNow = null): bool { + return $this->expires >= ($rNow ?? time()) + && NetworkUtils::ipMatches($rSubnetMatch, $this->ip, $rClientIp); + } +} diff --git a/src/Public/admin/api.php b/src/Public/admin/api.php index 70245593..22c04207 100644 --- a/src/Public/admin/api.php +++ b/src/Public/admin/api.php @@ -23,13 +23,11 @@ register_shutdown_function('shutdown'); set_time_limit(0); $rIP = NetworkUtils::getUserIP(); -if (in_array($rIP, ServerRepository::getAllowedIPs()) || in_array($rIP, SettingsManager::get('api_ips'))) { -} else { +if (!(in_array($rIP, ServerRepository::getAllowedIPs()) || in_array($rIP, SettingsManager::get('api_ips')))) { generate404(); } -if (empty(SettingsManager::get('api_pass')) || RequestManager::get('api_pass') == SettingsManager::get('api_pass')) { -} else { +if (!(empty(SettingsManager::get('api_pass')) || RequestManager::get('api_pass') == SettingsManager::get('api_pass'))) { generate404(); } @@ -198,8 +196,7 @@ switch ($rAction) { function shutdown() { global $db; - if (!is_object($db)) { - } else { + if (is_object($db)) { $db->close_mysql(); } } diff --git a/src/Public/admin/live.php b/src/Public/admin/live.php index d3edf24f..81ad97c2 100644 --- a/src/Public/admin/live.php +++ b/src/Public/admin/live.php @@ -4,10 +4,10 @@ use XcVm\Core\Config\SettingsManager; use XcVm\Core\Database\DatabaseHandler; use XcVm\Core\Http\RequestManager; use XcVm\Core\Process\ProcessManager; -use XcVm\Core\Util\Encryption; use XcVm\Core\Util\NetworkUtils; use XcVm\Core\Util\StreamUtils; use XcVm\Domain\Server\ServerRepository; +use XcVm\Domain\Stream\AdminStreamToken; use XcVm\Domain\Stream\ConnectionTracker; use XcVm\Domain\Stream\StreamProcess; use XcVm\Infrastructure\Database\DatabaseFactory; @@ -29,38 +29,30 @@ $rIP = NetworkUtils::getUserIP(); $rPID = getmypid(); $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'))); -if (SettingsManager::get('use_buffer') != 0) { -} else { +if (SettingsManager::get('use_buffer') == 0) { header('X-Accel-Buffering: no'); } if (!empty(RequestManager::get('uitoken'))) { - $rTokenData = json_decode(Encryption::decrypt(RequestManager::get('uitoken'), SettingsManager::get('live_streaming_pass'), OPENSSL_EXTRA), true); - RequestManager::update('stream', $rTokenData['stream_id']); - RequestManager::update('extension', 'm3u8'); - $rIPMatch = (SettingsManager::get('ip_subnet_match') ? implode('.', array_slice(explode('.', $rTokenData['ip']), 0, -1)) == implode('.', array_slice(explode('.', NetworkUtils::getUserIP()), 0, -1)) : $rTokenData['ip'] == NetworkUtils::getUserIP()); + $rToken = AdminStreamToken::decode(RequestManager::get('uitoken'), SettingsManager::get('live_streaming_pass')); - if ($rTokenData['expires'] >= time() && $rIPMatch) { - } else { + if ($rToken === null || !$rToken->isValid((bool) SettingsManager::get('ip_subnet_match'), $rIP)) { generate404(); } + RequestManager::update('stream', $rToken->streamId); + RequestManager::update('extension', 'm3u8'); $rPrebuffer = $rSegmentSettings['seg_time']; +} elseif (empty(RequestManager::get('password')) || SettingsManager::get('live_streaming_pass') != RequestManager::get('password')) { + generate404(); +} elseif (!in_array($rIP, ServerRepository::getAllowedIPs())) { + generate404(); } else { - if (empty(RequestManager::get('password')) || SettingsManager::get('live_streaming_pass') != RequestManager::get('password')) { - generate404(); - } else { - if (!in_array($rIP, ServerRepository::getAllowedIPs())) { - generate404(); - } else { - $rPrebuffer = (RequestManager::has('prebuffer') ? $rSegmentSettings['seg_time'] : 0); + $rPrebuffer = (RequestManager::has('prebuffer') ? $rSegmentSettings['seg_time'] : 0); - foreach (getallheaders() as $rKey => $rValue) { - if (strtoupper($rKey) != 'X-XC_VM-PREBUFFER') { - } else { - $rPrebuffer = $rSegmentSettings['seg_time']; - } - } + foreach (getallheaders() as $rKey => $rValue) { + if (strtoupper($rKey) == 'X-XC_VM-PREBUFFER') { + $rPrebuffer = $rSegmentSettings['seg_time']; } } } @@ -78,28 +70,23 @@ if (0 < $db->num_rows()) { $rChannelInfo = $db->get_row(); $db->close_mysql(); - if (!file_exists(STREAMS_PATH . $rStreamID . '_.pid')) { - } else { + if (file_exists(STREAMS_PATH . $rStreamID . '_.pid')) { $rChannelInfo['pid'] = intval(file_get_contents(STREAMS_PATH . $rStreamID . '_.pid')); } - if (!file_exists(STREAMS_PATH . $rStreamID . '_.monitor')) { - } else { + if (file_exists(STREAMS_PATH . $rStreamID . '_.monitor')) { $rChannelInfo['monitor_pid'] = intval(file_get_contents(STREAMS_PATH . $rStreamID . '_.monitor')); } - if (!(SettingsManager::get('on_demand_instant_off') && $rChannelInfo['on_demand'] == 1)) { - } else { + if ((SettingsManager::get('on_demand_instant_off') && $rChannelInfo['on_demand'] == 1)) { ConnectionTracker::addToQueue($rStreamID, $rPID); } - if (ProcessManager::isStreamRunning($rChannelInfo['pid'], $rStreamID)) { - } else { + if (!ProcessManager::isStreamRunning($rChannelInfo['pid'], $rStreamID)) { $rChannelInfo['pid'] = null; if ($rChannelInfo['on_demand'] == 1) { - if (ProcessManager::isMonitorAlive($rChannelInfo['monitor_pid'], $rStreamID)) { - } else { + if (!ProcessManager::isMonitorAlive($rChannelInfo['monitor_pid'], $rStreamID)) { StreamProcess::startMonitor($rStreamID); for ($rRetries = 0; !file_exists(STREAMS_PATH . intval($rStreamID) . '_.monitor') && $rRetries < 300; $rRetries++) { @@ -116,14 +103,12 @@ if (0 < $db->num_rows()) { $rPlaylist = STREAMS_PATH . $rStreamID . '_.m3u8'; if ($rExtension == 'ts') { - if (file_exists($rPlaylist)) { - } else { + if (!file_exists($rPlaylist)) { $rFirstTS = STREAMS_PATH . $rStreamID . '_0.ts'; $rFP = null; while ($rRetries < intval($rWaitTime) * 100) { - if (!file_exists($rFirstTS) || $rFP) { - } else { + if (file_exists($rFirstTS) && !$rFP) { $rFP = fopen($rFirstTS, 'r'); } @@ -135,8 +120,7 @@ if (0 < $db->num_rows()) { } } - if (!$rFP) { - } else { + if ($rFP) { fclose($rFP); } } @@ -158,26 +142,22 @@ if (0 < $db->num_rows()) { generate404(); } else { - if (!RequestManager::has('odstart')) { - } else { + if (RequestManager::has('odstart')) { echo '1'; exit(); } } - if ($rChannelInfo['pid']) { - } else { + if (!$rChannelInfo['pid']) { $rChannelInfo['pid'] = intval(file_get_contents(STREAMS_PATH . $rStreamID . '_.pid')); } switch ($rExtension) { case 'm3u8': - if (!StreamUtils::isValidStream($rPlaylist, $rChannelInfo['pid'])) { - } else { + if (StreamUtils::isValidStream($rPlaylist, $rChannelInfo['pid'])) { if (empty(RequestManager::get('segment'))) { - if (!($rSource = StreamUtils::generateAdminHLS($rPlaylist, $rPassword, $rStreamID, RequestManager::get('uitoken')))) { - } else { + if (($rSource = StreamUtils::generateAdminHLS($rPlaylist, $rPassword, $rStreamID, RequestManager::get('uitoken')))) { header('Content-Type: application/vnd.apple.mpegurl'); header('Content-Length: ' . strlen($rSource)); ob_end_flush(); @@ -186,10 +166,9 @@ if (0 < $db->num_rows()) { exit(); } } else { - $rSegment = STREAMS_PATH . str_replace(array('\\', '/'), '', urldecode(RequestManager::get('segment'))); + $rSegment = STREAMS_PATH . StreamUtils::sanitizeSegmentName(RequestManager::get('segment')); - if (!file_exists($rSegment)) { - } else { + if (file_exists($rSegment)) { $rBytes = filesize($rSegment); header('Content-Length: ' . $rBytes); header('Content-Type: video/mp2t'); @@ -206,12 +185,10 @@ if (0 < $db->num_rows()) { header('Content-Type: video/mp2t'); if (file_exists($rPlaylist)) { - if (!file_exists(STREAMS_PATH . $rStreamID . '_.dur')) { - } else { + if (file_exists(STREAMS_PATH . $rStreamID . '_.dur')) { $rDuration = intval(file_get_contents(STREAMS_PATH . $rStreamID . '_.dur')); - if ($rSegmentSettings['seg_time'] >= $rDuration) { - } else { + if ($rSegmentSettings['seg_time'] < $rDuration) { $rSegmentSettings['seg_time'] = $rDuration; } } @@ -249,12 +226,7 @@ if (0 < $db->num_rows()) { } $rFails = 0; - $rTotalFails = $rSegmentSettings['seg_time'] * 2; - - if (!(($rTotalFails < intval(SettingsManager::get('segment_wait_time')) ?: 20))) { - } else { - $rTotalFails = (intval(SettingsManager::get('segment_wait_time')) ?: 20); - } + $rTotalFails = StreamUtils::segmentRetryBudget($rSegmentSettings['seg_time'], intval(SettingsManager::get('segment_wait_time'))); while (true) { $rSegmentFile = sprintf('%d_%d.ts', $rStreamID, $rCurrent + 1); @@ -270,8 +242,7 @@ if (0 < $db->num_rows()) { exit(); } - if (!(empty($rChannelInfo['pid']) && file_exists(STREAMS_PATH . $rStreamID . '_.pid'))) { - } else { + if ((empty($rChannelInfo['pid']) && file_exists(STREAMS_PATH . $rStreamID . '_.pid'))) { $rChannelInfo['pid'] = intval(file_get_contents(STREAMS_PATH . $rStreamID . '_.pid')); } @@ -342,13 +313,11 @@ function shutdown() { global $rPID; global $rStreamID; - if (!is_object($db)) { - } else { + if (is_object($db)) { $db->close_mysql(); } - if (!(SettingsManager::get('on_demand_instant_off') && $rChannelInfo['on_demand'] == 1)) { - } else { + if ((SettingsManager::get('on_demand_instant_off') && $rChannelInfo['on_demand'] == 1)) { ConnectionTracker::removeFromQueue($rStreamID, $rPID); } } diff --git a/src/Public/admin/proxy_api.php b/src/Public/admin/proxy_api.php index 1f24cf6d..44f7e813 100644 --- a/src/Public/admin/proxy_api.php +++ b/src/Public/admin/proxy_api.php @@ -27,10 +27,11 @@ if (BlocklistService::isProxy($_SERVER['REMOTE_ADDR'])) { $rStats = $_POST['stats']; $db->query('SELECT `bytes_sent_total`, `bytes_received_total`, `time` FROM `servers_stats` WHERE `server_id` = ? ORDER BY `id` DESC LIMIT 1;', $rServerIDRequest); - if ($db->num_rows() != 1) { - } else { + if ($db->num_rows() == 1) { $rRow = $db->get_row(); - $rTimeSince = time() - $rRow['time']; + // Clamp to >= 1s: two samples in the same second (or a backward clock) would + // otherwise divide by zero / a negative interval. + $rTimeSince = max(1, time() - $rRow['time']); $rStats['bytes_sent'] = ($rStats['bytes_sent_total'] - $rRow['bytes_sent_total']) / $rTimeSince; $rStats['bytes_received'] = ($rStats['bytes_received_total'] - $rRow['bytes_received_total']) / $rTimeSince; } @@ -39,8 +40,7 @@ if (BlocklistService::isProxy($_SERVER['REMOTE_ADDR'])) { $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']); $rPing = (pingserver($rServers[$rServerIDRequest]['server_ip'], $rServers[$rServerIDRequest]['http_broadcast_port']) ?: 0); - if ($rPing >= 0) { - } else { + if ($rPing < 0) { $rPing = 0; } @@ -50,8 +50,7 @@ if (BlocklistService::isProxy($_SERVER['REMOTE_ADDR'])) { $rAllUsers = 0; foreach (array_keys($rServers) as $rServer) { - if (!$rServers[$rServer]['server_online']) { - } else { + if ($rServers[$rServer]['server_online']) { $rAllUsers += $rServers[$rServer]['users']; } } @@ -70,8 +69,7 @@ if (BlocklistService::isProxy($_SERVER['REMOTE_ADDR'])) { if ($db->query("SELECT `signal_id`, `custom_data` FROM `signals` WHERE `server_id` = ? AND `custom_data` <> '' ORDER BY signal_id ASC;", $rServerIDRequest)) { - if (0 >= $db->num_rows()) { - } else { + if (0 < $db->num_rows()) { foreach ($db->get_rows() as $rRow) { $rData = json_decode($rRow['custom_data'], true); $db->query('DELETE FROM `signals` WHERE `signal_id` = ?;', $rRow['signal_id']); diff --git a/src/Public/admin/thumb.php b/src/Public/admin/thumb.php index 7a01b423..6f971b25 100644 --- a/src/Public/admin/thumb.php +++ b/src/Public/admin/thumb.php @@ -3,9 +3,9 @@ use XcVm\Core\Config\SettingsManager; use XcVm\Core\Database\DatabaseHandler; use XcVm\Core\Http\RequestManager; -use XcVm\Core\Util\Encryption; use XcVm\Core\Util\NetworkUtils; use XcVm\Domain\Server\ServerRepository; +use XcVm\Domain\Stream\AdminStreamToken; use XcVm\Infrastructure\Database\DatabaseFactory; /** @@ -24,14 +24,13 @@ set_time_limit(0); $rIP = NetworkUtils::getUserIP(); if (!empty(RequestManager::get('uitoken'))) { - $rTokenData = json_decode(Encryption::decrypt(RequestManager::get('uitoken'), SettingsManager::get('live_streaming_pass'), OPENSSL_EXTRA), true); - RequestManager::update('stream', $rTokenData['stream_id']); - $rIPMatch = (SettingsManager::get('ip_subnet_match') ? implode('.', array_slice(explode('.', $rTokenData['ip']), 0, -1)) == implode('.', array_slice(explode('.', NetworkUtils::getUserIP()), 0, -1)) : $rTokenData['ip'] == NetworkUtils::getUserIP()); + $rToken = AdminStreamToken::decode(RequestManager::get('uitoken'), SettingsManager::get('live_streaming_pass')); - if ($rTokenData['expires'] >= time() && $rIPMatch) { - } else { + if ($rToken === null || !$rToken->isValid((bool) SettingsManager::get('ip_subnet_match'), $rIP)) { generate404(); } + + RequestManager::update('stream', $rToken->streamId); } else { generate404(); } @@ -42,8 +41,7 @@ $rStreamID = intval(RequestManager::get('stream')); $rStream = array(); $db->query('SELECT * FROM `streams` t1 INNER JOIN `streams_types` t2 ON t2.type_id = t1.type AND t2.live = 1 LEFT JOIN `profiles` t4 ON t1.transcode_profile_id = t4.profile_id WHERE t1.direct_source = 0 AND t1.id = ?', $rStreamID); -if ($db->num_rows() > 0) { -} else { +if ($db->num_rows() <= 0) { generate404(); } @@ -69,8 +67,7 @@ if (SERVER_ID == $rStream['vframes_server_id']) { function shutdown() { global $db; - if (!is_object($db)) { - } else { + if (is_object($db)) { $db->close_mysql(); } } diff --git a/src/Public/admin/timeshift.php b/src/Public/admin/timeshift.php index 2700a8a2..d6613ba8 100644 --- a/src/Public/admin/timeshift.php +++ b/src/Public/admin/timeshift.php @@ -3,9 +3,10 @@ use XcVm\Core\Config\SettingsManager; use XcVm\Core\Database\DatabaseHandler; use XcVm\Core\Http\RequestManager; -use XcVm\Core\Util\Encryption; use XcVm\Core\Util\NetworkUtils; +use XcVm\Core\Util\StreamUtils; use XcVm\Domain\Server\ServerRepository; +use XcVm\Domain\Stream\AdminStreamToken; use XcVm\Infrastructure\Database\DatabaseFactory; /** @@ -30,23 +31,21 @@ if (SettingsManager::get('use_buffer') == 0) { if (!empty($rRequestData['uitoken'])) { - $rTokenData = json_decode(Encryption::decrypt($rRequestData['uitoken'], SettingsManager::get('live_streaming_pass'), OPENSSL_EXTRA), true); - RequestManager::update('stream', $rTokenData['stream_id']); + $rToken = AdminStreamToken::decode($rRequestData['uitoken'], SettingsManager::get('live_streaming_pass')); + + if ($rToken === null || !$rToken->isValid((bool) SettingsManager::get('ip_subnet_match'), $rIP)) { + generate404(); + } + + RequestManager::update('stream', $rToken->streamId); RequestManager::update('extension', 'm3u8'); - if (isset($rTokenData['start'])) { - RequestManager::update('start', $rTokenData['start']); + if ($rToken->start !== null) { + RequestManager::update('start', $rToken->start); } - if (isset($rTokenData['duration'])) { - RequestManager::update('duration', $rTokenData['duration']); - } - - $rIPMatch = (SettingsManager::get('ip_subnet_match') ? implode('.', array_slice(explode('.', $rTokenData['ip']), 0, -1)) == implode('.', array_slice(explode('.', NetworkUtils::getUserIP()), 0, -1)) : $rTokenData['ip'] == NetworkUtils::getUserIP()); - - if ($rTokenData['expires'] >= time() && $rIPMatch) { - } else { - generate404(); + if ($rToken->duration !== null) { + RequestManager::update('duration', $rToken->duration); } } elseif (!in_array($rIP, ServerRepository::getAllowedIPs())) { generate404(); @@ -64,24 +63,7 @@ if (empty($rRequestData['segment'])) { $rStartDate = $rRequestData['start']; $rDuration = $rRequestData['duration']; - if (!is_numeric($rStartDate)) { - if (substr_count($rStartDate, '-') == 1) { - list($rDate, $rTime) = explode('-', $rStartDate); - $rYear = substr($rDate, 0, 4); - $rMonth = substr($rDate, 4, 2); - $rDay = substr($rDate, 6, 2); - $rMinutes = 0; - $rHour = $rTime; - } else { - list($rDate, $rTime) = explode(':', $rStartDate); - list($rYear, $rMonth, $rDay) = explode('-', $rDate); - list($rHour, $rMinutes) = explode('-', $rTime); - } - - $rTimestamp = mktime($rHour, $rMinutes, 0, $rMonth, $rDay, $rYear); - } else { - $rTimestamp = $rStartDate; - } + $rTimestamp = StreamUtils::timeshiftStartTimestamp($rStartDate); } $db->query('SELECT * FROM `streams` t1 INNER JOIN `streams_servers` t2 ON t2.stream_id = t1.id AND t2.server_id = ? WHERE t1.`id` = ?', SERVER_ID, $rStreamID); @@ -146,7 +128,7 @@ if (0 < $db->num_rows()) { exit(); } else { - $rSegment = ARCHIVE_PATH . $rStreamID . '/' . str_replace(array('\\', '/'), '', urldecode($rRequestData['segment'])); + $rSegment = ARCHIVE_PATH . $rStreamID . '/' . StreamUtils::sanitizeSegmentName($rRequestData['segment']); if (file_exists($rSegment)) { $rBytes = filesize($rSegment); @@ -206,8 +188,7 @@ if (0 < $db->num_rows()) { header('Content-Length: ' . $rLength); $rStartFrom = 0; - if (0 >= $rStart) { - } else { + if (0 < $rStart) { $rStartFrom = floor($rStart / ($rSize / count($rQueue))); } @@ -219,10 +200,8 @@ if (0 < $db->num_rows()) { foreach ($rQueue as $rKey => $rItem) { $rSizeToDate += $rItem['filesize']; - if ($rFirstFile || 0 >= $rStartFrom) { - } else { - if ($rKey < $rStartFrom) { - } else { + if (!($rFirstFile || 0 >= $rStartFrom)) { + if ($rKey >= $rStartFrom) { $rFirstFile = true; $rSeekTo = $rStart - $rSizeToDate; } diff --git a/src/Public/admin/vod.php b/src/Public/admin/vod.php index 2eb92bef..aa48fc7e 100644 --- a/src/Public/admin/vod.php +++ b/src/Public/admin/vod.php @@ -3,9 +3,10 @@ use XcVm\Core\Config\SettingsManager; use XcVm\Core\Database\DatabaseHandler; use XcVm\Core\Http\RequestManager; -use XcVm\Core\Util\Encryption; use XcVm\Core\Util\NetworkUtils; +use XcVm\Core\Util\StreamUtils; use XcVm\Domain\Server\ServerRepository; +use XcVm\Domain\Stream\AdminStreamToken; use XcVm\Infrastructure\Database\DatabaseFactory; /** @@ -24,30 +25,20 @@ set_time_limit(0); $rIP = NetworkUtils::getUserIP(); if (!empty(RequestManager::get('uitoken'))) { - $rTokenData = json_decode(Encryption::decrypt(RequestManager::get('uitoken'), SettingsManager::get('live_streaming_pass'), OPENSSL_EXTRA), true); - RequestManager::update('stream', $rTokenData['stream_id'] . '.' . $rTokenData['container']); - $rIPMatch = (SettingsManager::get('ip_subnet_match') ? implode('.', array_slice(explode('.', $rTokenData['ip']), 0, -1)) == implode('.', array_slice(explode('.', NetworkUtils::getUserIP()), 0, -1)) : $rTokenData['ip'] == NetworkUtils::getUserIP()); + $rToken = AdminStreamToken::decode(RequestManager::get('uitoken'), SettingsManager::get('live_streaming_pass')); - if ($rTokenData['expires'] >= time() && $rIPMatch) { - } else { + if ($rToken === null || !$rToken->isValid((bool) SettingsManager::get('ip_subnet_match'), $rIP)) { generate404(); } -} else { - if (!in_array($rIP, ServerRepository::getAllowedIPs())) { - generate404(); - } else { - if (!(empty(RequestManager::get('password')) || SettingsManager::get('live_streaming_pass') != RequestManager::get('password'))) { - } else { - - - generate404(); - } - } + RequestManager::update('stream', $rToken->streamId . '.' . $rToken->container); +} elseif (!in_array($rIP, ServerRepository::getAllowedIPs())) { + generate404(); +} elseif (empty(RequestManager::get('password')) || SettingsManager::get('live_streaming_pass') != RequestManager::get('password')) { + generate404(); } -if (!empty(RequestManager::get('stream'))) { -} else { +if (empty(RequestManager::get('stream'))) { generate404(); } @@ -58,63 +49,17 @@ $rStreamID = intval($rStream['filename']); $rExtension = $rStream['extension']; $db->query("SELECT t1.* FROM `streams` t1 INNER JOIN `streams_servers` t2 ON t2.stream_id = t1.id AND t2.pid IS NOT NULL AND t2.server_id = ? INNER JOIN `streams_types` t3 ON t3.type_id = t1.type AND t3.type_key IN ('movie', 'series') WHERE t1.`id` = ?", SERVER_ID, $rStreamID); -if (SettingsManager::get('use_buffer') != 0) { -} else { +if (SettingsManager::get('use_buffer') == 0) { header('X-Accel-Buffering: no'); } -if (0 >= $db->num_rows()) { -} else { +if (0 < $db->num_rows()) { $rInfo = $db->get_row(); $db->close_mysql(); $rRequest = VOD_PATH . $rStreamID . '.' . $rExtension; - if (!file_exists($rRequest)) { - } else { - switch ($rInfo['target_container']) { - case 'mp4': - header('Content-type: video/mp4'); - - break; - - case 'mkv': - header('Content-type: video/x-matroska'); - - break; - - case 'avi': - header('Content-type: video/x-msvideo'); - - break; - - case '3gp': - header('Content-type: video/3gpp'); - - break; - - case 'flv': - header('Content-type: video/x-flv'); - - break; - - case 'wmv': - header('Content-type: video/x-ms-wmv'); - - break; - - case 'mov': - header('Content-type: video/quicktime'); - - break; - - case 'ts': - header('Content-type: video/mp2t'); - - break; - - default: - header('Content-Type: application/octet-stream'); - } + if (file_exists($rRequest)) { + header('Content-Type: ' . StreamUtils::containerMimeType($rInfo['target_container'])); $rFile = @fopen($rRequest, 'rb'); $rSize = filesize($rRequest); $rLength = $rSize; @@ -122,8 +67,7 @@ if (0 >= $db->num_rows()) { $rEnd = $rSize - 1; header('Accept-Ranges: 0-' . $rLength); - if (!isset($_SERVER['HTTP_RANGE'])) { - } else { + if (isset($_SERVER['HTTP_RANGE'])) { $rRangeStart = $rStart; $rRangeEnd = $rEnd; list(, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2); @@ -176,8 +120,7 @@ if (0 >= $db->num_rows()) { function shutdown() { global $db; - if (!is_object($db)) { - } else { + if (is_object($db)) { $db->close_mysql(); } } diff --git a/tests/Unit/AdminStreamTokenTest.php b/tests/Unit/AdminStreamTokenTest.php new file mode 100644 index 00000000..9076e684 --- /dev/null +++ b/tests/Unit/AdminStreamTokenTest.php @@ -0,0 +1,73 @@ + $data */ + private function makeToken(array $data): string { + return Encryption::encrypt(json_encode($data), self::KEY, OPENSSL_EXTRA); + } + + public function testDecodeReturnsNullOnGarbageCiphertext() { + $this->assertNull(AdminStreamToken::decode('not-a-valid-token', self::KEY)); + } + + public function testDecodeReturnsNullOnWrongKey() { + $token = $this->makeToken(array('stream_id' => 7, 'ip' => '1.2.3.4', 'expires' => time() + 60)); + $this->assertNull(AdminStreamToken::decode($token, 'wrong-key')); + } + + public function testDecodeReturnsNullWhenRequiredFieldMissing() { + $token = $this->makeToken(array('stream_id' => 7, 'ip' => '1.2.3.4')); // no expires + $this->assertNull(AdminStreamToken::decode($token, self::KEY)); + } + + public function testDecodeExposesTypedFieldsAndKeepsStartRaw() { + $token = $this->makeToken(array( + 'stream_id' => '7', + 'ip' => '1.2.3.4', + 'expires' => '123', + 'container' => 'mp4', + 'start' => '20250101-13', + 'duration' => 30, + )); + $rToken = AdminStreamToken::decode($token, self::KEY); + + $this->assertNotNull($rToken); + $this->assertSame(7, $rToken->streamId); + $this->assertSame('1.2.3.4', $rToken->ip); + $this->assertSame(123, $rToken->expires); + $this->assertSame('mp4', $rToken->container); + $this->assertSame('20250101-13', $rToken->start); // raw — NOT cast to int + $this->assertSame(30, $rToken->duration); + } + + public function testIsValidExpiryBoundaryIsInclusive() { + $rToken = AdminStreamToken::decode($this->makeToken(array('stream_id' => 1, 'ip' => '1.2.3.4', 'expires' => 1000)), self::KEY); + + $this->assertTrue($rToken->isValid(false, '1.2.3.4', 1000)); // expires == now → valid + $this->assertFalse($rToken->isValid(false, '1.2.3.4', 1001)); // now past expiry + } + + public function testIsValidExactIpMatch() { + $rToken = AdminStreamToken::decode($this->makeToken(array('stream_id' => 1, 'ip' => '1.2.3.4', 'expires' => 2000)), self::KEY); + + $this->assertTrue($rToken->isValid(false, '1.2.3.4', 1000)); + $this->assertFalse($rToken->isValid(false, '1.2.3.5', 1000)); + } + + public function testIsValidSubnetMatch() { + $rToken = AdminStreamToken::decode($this->makeToken(array('stream_id' => 1, 'ip' => '1.2.3.4', 'expires' => 2000)), self::KEY); + + $this->assertTrue($rToken->isValid(true, '1.2.3.99', 1000)); // same /24 + $this->assertFalse($rToken->isValid(true, '1.2.9.4', 1000)); // different third octet + } +} diff --git a/tests/Unit/NetworkUtilsTest.php b/tests/Unit/NetworkUtilsTest.php new file mode 100644 index 00000000..e6eb6358 --- /dev/null +++ b/tests/Unit/NetworkUtilsTest.php @@ -0,0 +1,25 @@ +assertTrue(NetworkUtils::ipMatches(false, '1.2.3.4', '1.2.3.4')); + $this->assertFalse(NetworkUtils::ipMatches(false, '1.2.3.4', '1.2.3.5')); + } + + public function testSubnetMatchIgnoresLastOctet() { + $this->assertTrue(NetworkUtils::ipMatches(true, '1.2.3.4', '1.2.3.99')); + $this->assertFalse(NetworkUtils::ipMatches(true, '1.2.3.4', '1.2.9.4')); + } + + public function testNullIpsAreHandled() { + $this->assertTrue(NetworkUtils::ipMatches(false, null, null)); + $this->assertFalse(NetworkUtils::ipMatches(false, '1.2.3.4', null)); + } +} diff --git a/tests/Unit/StreamUtilsTest.php b/tests/Unit/StreamUtilsTest.php index 4dd91c03..cb9649a0 100644 --- a/tests/Unit/StreamUtilsTest.php +++ b/tests/Unit/StreamUtilsTest.php @@ -20,4 +20,31 @@ final class StreamUtilsTest extends TestCase { public function testDetectXcVmRejectsUnrelatedPaths() { $this->assertFalse(StreamUtils::detectXC_VM('http://host/dashboard')); } + + public function testSanitizeSegmentNameStripsSeparators() { + $this->assertSame('seg_0.ts', StreamUtils::sanitizeSegmentName('seg_0.ts')); + $this->assertSame('....etcpasswd', StreamUtils::sanitizeSegmentName('../../etc/passwd')); + // URL-encoded traversal: "%2e%2e%2fpasswd" → "../passwd" → strip "/" → "..passwd". + $this->assertSame('..passwd', StreamUtils::sanitizeSegmentName('%2e%2e%2fpasswd')); + } + + public function testContainerMimeType() { + $this->assertSame('video/mp4', StreamUtils::containerMimeType('mp4')); + $this->assertSame('video/x-matroska', StreamUtils::containerMimeType('mkv')); + $this->assertSame('video/mp2t', StreamUtils::containerMimeType('ts')); + $this->assertSame('application/octet-stream', StreamUtils::containerMimeType('xyz')); + $this->assertSame('application/octet-stream', StreamUtils::containerMimeType('')); + } + + public function testTimeshiftStartTimestamp() { + $this->assertSame(1700000000, StreamUtils::timeshiftStartTimestamp('1700000000')); + $this->assertSame(mktime(13, 0, 0, 1, 1, 2025), StreamUtils::timeshiftStartTimestamp('20250101-13')); + $this->assertSame(mktime(13, 30, 0, 1, 1, 2025), StreamUtils::timeshiftStartTimestamp('2025-01-01:13-30')); + } + + public function testSegmentRetryBudget() { + $this->assertSame(20, StreamUtils::segmentRetryBudget(10, 5)); // seg_time*2 wins (the fixed bug) + $this->assertSame(30, StreamUtils::segmentRetryBudget(3, 30)); // configured wins + $this->assertSame(20, StreamUtils::segmentRetryBudget(3, 0)); // 0 → default floor 20 + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 84370d39..e7a8da3f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -35,6 +35,11 @@ if (!defined('MAIN_HOME')) { if (!defined('PHP_BIN')) { define('PHP_BIN', PHP_BINARY); } +// Extra OpenSSL entropy/seed used by Encryption (real value in AppConfig.php). +// Any stable value works for tests that round-trip encrypt/decrypt. +if (!defined('OPENSSL_EXTRA')) { + define('OPENSSL_EXTRA', 'test-openssl-extra'); +} // FfmpegPaths::binary() builds paths under BIN_PATH . 'ffmpeg_bin//'. No such // fixtures exist here, so lookups miss and the code falls back to FFMPEG_BIN_40. if (!defined('BIN_PATH')) {