mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-09 04:04:21 +02:00
refactor(bootstrap): split XC_Bootstrap into an injectable BootKernel pipeline
Replace the fully-static XC_Bootstrap god-class with a stage pipeline so the boot logic becomes unit-testable and the per-context sequences are explicit. - BootState replaces the 8 static readiness flags with a value object threaded through the pipeline; stages read/write it instead of static state. - BootStageInterface + BootPipeline run an ordered stage list and abort loudly on a throwing stage. - 16 stages under Core/Bootstrap/Stage/ hold one subsystem each, extracted verbatim from the old private methods (constants, config, flood, host, session, database, legacy core, redis, process title, admin API, translator, admin shutdown, status constants, admin globals, container populate, health check). - StageProfiles builds the ordered list per context, mirroring the exact previous sequence; BootKernel resolves options, sets up the container and runs it. - XC_Bootstrap is now a thin BC facade delegating to BootKernel; its getters read the returned BootState. reset() also clears EventDispatcher and the new DatabaseFactory::reset() (a side-effect-free registry clear for test isolation). The DB-touching contexts (Cli/Stream/Admin) still require a live MySQL and the xcvm_core extension, so they are verified on a canary rather than in CI; the Minimal context and the pipeline/profile composition are covered by new tests.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap;
|
||||
|
||||
use XcVm\Core\Container\ServiceContainer;
|
||||
use XcVm\Core\Enum\BootContext;
|
||||
|
||||
/**
|
||||
* Orchestrates a context-scoped boot: resolves options, sets up the container,
|
||||
* builds the stage list for the context and runs it against a fresh BootState.
|
||||
*
|
||||
* The XC_Bootstrap global class is now a thin static facade over this kernel;
|
||||
* WebApiBootstrap / StreamingRequestBootstrap converge here in later steps.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class BootKernel {
|
||||
/**
|
||||
* @param array<string,mixed> $options Caller options, merged over the context defaults.
|
||||
*/
|
||||
public function boot(BootContext $context, array $options = []): BootState {
|
||||
$resolved = array_merge(self::defaults($context), $options);
|
||||
|
||||
$container = ServiceContainer::getInstance();
|
||||
$container->set('context', $context->value);
|
||||
$container->set('options', $resolved);
|
||||
|
||||
$state = new BootState($context, $resolved, $container);
|
||||
|
||||
(new BootPipeline(StageProfiles::for($context, $resolved)))->run($state);
|
||||
|
||||
$state->booted = true;
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default boot options for a context.
|
||||
*
|
||||
* @return array{cached: bool, redis: bool, process: string, shutdown: ?callable}
|
||||
*/
|
||||
public static function defaults(BootContext $context): array {
|
||||
return match ($context) {
|
||||
BootContext::Admin => ['cached' => false, 'redis' => true, 'process' => '', 'shutdown' => null],
|
||||
BootContext::Stream => ['cached' => true, 'redis' => false, 'process' => '', 'shutdown' => null],
|
||||
BootContext::Cli => ['cached' => false, 'redis' => false, 'process' => '', 'shutdown' => null],
|
||||
BootContext::Minimal => ['cached' => false, 'redis' => false, 'process' => '', 'shutdown' => null],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap;
|
||||
|
||||
/**
|
||||
* Runs an ordered list of boot stages against a BootState.
|
||||
*
|
||||
* A failing stage aborts the pipeline (it throws), mirroring the fail-loud
|
||||
* behaviour of the old monolithic boot(). The stage list is introspectable so
|
||||
* tests can assert per-context ordering.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class BootPipeline {
|
||||
/**
|
||||
* @param list<BootStageInterface> $stages Ordered stages.
|
||||
*/
|
||||
public function __construct(private array $stages) {
|
||||
}
|
||||
|
||||
public function run(BootState $state): void {
|
||||
foreach ($this->stages as $stage) {
|
||||
$stage->run($state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<BootStageInterface>
|
||||
*/
|
||||
public function stages(): array {
|
||||
return $this->stages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap;
|
||||
|
||||
/**
|
||||
* One step of a boot pipeline.
|
||||
*
|
||||
* A stage mutates the shared BootState (sets readiness flags, registers services,
|
||||
* defines constants) and is otherwise self-contained, so it can be constructed
|
||||
* with fakes and run against a hand-built BootState in a unit test.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
interface BootStageInterface {
|
||||
public function run(BootState $state): void;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap;
|
||||
|
||||
use XcVm\Core\Container\ServiceContainer;
|
||||
use XcVm\Core\Database\DatabaseHandler;
|
||||
use XcVm\Core\Enum\BootContext;
|
||||
|
||||
/**
|
||||
* Mutable state threaded through a boot pipeline.
|
||||
*
|
||||
* Replaces the per-subsystem static flags of the old XC_Bootstrap god-class.
|
||||
* Each stage reads and writes this object instead of static state, which is what
|
||||
* makes stages runnable and assertable in isolation. The readiness flags are
|
||||
* consumed by ContainerPopulateStage / HealthCheckStage to decide what to
|
||||
* register and verify.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class BootState {
|
||||
public bool $booted = false;
|
||||
|
||||
public bool $devMode = false;
|
||||
|
||||
public bool $constantsLoaded = false;
|
||||
|
||||
public bool $configLoaded = false;
|
||||
|
||||
public bool $loggerStarted = false;
|
||||
|
||||
public bool $databaseReady = false;
|
||||
|
||||
public bool $coreReady = false;
|
||||
|
||||
public bool $adminReady = false;
|
||||
|
||||
public bool $sessionStarted = false;
|
||||
|
||||
public bool $redisReady = false;
|
||||
|
||||
public ?DatabaseHandler $db = null;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $options Resolved boot options (cached/redis/process/shutdown).
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly BootContext $context,
|
||||
public readonly array $options,
|
||||
public readonly ServiceContainer $container,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the process is running under the CLI SAPI.
|
||||
*
|
||||
* Several stages (flood/host/session) are HTTP-only and self-skip on CLI.
|
||||
*/
|
||||
public function isCli(): bool {
|
||||
return php_sapi_name() === 'cli' || defined('STDIN');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
use XcVm\Domain\User\ResellerAPI;
|
||||
use XcVm\Domain\User\UserRepository;
|
||||
|
||||
/**
|
||||
* Initialize the admin/reseller API surface: resolve the logged-in admin user
|
||||
* and boot ResellerAPI.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class AdminApiStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
if ($state->adminReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($_SESSION['hash'])) {
|
||||
$GLOBALS['rAdminUserInfo'] = UserRepository::getRegisteredUserById($_SESSION['hash']);
|
||||
}
|
||||
|
||||
ResellerAPI::init();
|
||||
|
||||
$state->adminReady = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
use XcVm\Core\Config\ConfigReader;
|
||||
use XcVm\Core\Config\SettingsManager;
|
||||
use XcVm\Core\Localization\Translator;
|
||||
use XcVm\Domain\Server\ServerRepository;
|
||||
|
||||
/**
|
||||
* Initialize the legacy admin view globals: MobileDetect, timeouts, server
|
||||
* lists, protocol, allowed languages, and the reseller assets symlink.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class AdminGlobalsStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
global $rDetect, $rMobile, $rTimeout, $rSQLTimeout, $rProtocol,
|
||||
$allServers, $rServers, $rSettings, $rProxyServers,
|
||||
$rPermissions, $allowedLangs;
|
||||
|
||||
if (!defined('SERVER_ID')) {
|
||||
define('SERVER_ID', intval(ConfigReader::get('server_id')));
|
||||
}
|
||||
|
||||
$rDetect = new \Detection\MobileDetect();
|
||||
$rMobile = $rDetect->isMobile();
|
||||
|
||||
$rTimeout = 15;
|
||||
$rSQLTimeout = 10;
|
||||
set_time_limit($rTimeout);
|
||||
ini_set('mysql.connect_timeout', (string) $rSQLTimeout);
|
||||
ini_set('max_execution_time', (string) $rTimeout);
|
||||
ini_set('default_socket_timeout', (string) $rTimeout);
|
||||
|
||||
$rProtocol = $this->detectProtocol();
|
||||
$allServers = ServerRepository::getAllSimple();
|
||||
$rServers = ServerRepository::getStreamingSimple($rPermissions);
|
||||
$rSettings = SettingsManager::getAll();
|
||||
if ($state->devMode) {
|
||||
$rSettings['debug_show_errors'] = true;
|
||||
}
|
||||
$rProxyServers = ServerRepository::getProxySimple($rPermissions);
|
||||
|
||||
$allowedLangs = Translator::available();
|
||||
|
||||
// Sort servers by order
|
||||
if (is_array($rServers)) {
|
||||
uasort(
|
||||
$rServers,
|
||||
function ($a, $b) {
|
||||
return $a['order'] - $b['order'];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure the legacy 'reseller' assets alias (Public/assets/reseller → admin).
|
||||
$this->ensureResellerAssetsSymlink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the legacy 'reseller' assets alias exists (Public/assets/reseller → admin).
|
||||
*
|
||||
* Reseller pages reuse the admin asset bundle, and some nginx configs / legacy
|
||||
* routes expect Public/assets/reseller to resolve to Public/assets/admin. The
|
||||
* link is not stored in git (an absolute-path symlink broke the build), so the
|
||||
* panel recreates it here. Idempotent, and repairs a stale/broken link.
|
||||
*/
|
||||
private function ensureResellerAssetsSymlink(): void {
|
||||
$assetsBase = MAIN_HOME . 'Public/assets/';
|
||||
$resellerLink = $assetsBase . 'reseller';
|
||||
|
||||
// Nothing to point at yet — skip.
|
||||
if (!is_dir($assetsBase . 'admin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_link($resellerLink)) {
|
||||
// Correct, resolvable link → nothing to do.
|
||||
if (readlink($resellerLink) === 'admin' && is_dir($resellerLink)) {
|
||||
return;
|
||||
}
|
||||
@unlink($resellerLink); // stale/broken link — recreate below
|
||||
} elseif (file_exists($resellerLink)) {
|
||||
return; // a real directory/file lives here — leave it alone
|
||||
}
|
||||
|
||||
// Relative link so it is independent of MAIN_HOME and path case.
|
||||
if (!@symlink('admin', $resellerLink)) {
|
||||
// Without the link every reseller access code serves pages with no
|
||||
// CSS/JS (nginx aliases /CODE/assets/ to Public/assets/reseller/).
|
||||
error_log('AdminGlobalsStage: failed to create Public/assets/reseller -> admin symlink — check ownership of Public/assets/ (expected xc_vm).');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect HTTP protocol (http/https).
|
||||
*/
|
||||
private function detectProtocol(): string {
|
||||
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
$port443 = isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443;
|
||||
return ($https || $port443) ? 'https' : 'http';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
|
||||
/**
|
||||
* Register a shutdown handler that closes the MySQL connection on script end.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class AdminShutdownStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
register_shutdown_function(function () {
|
||||
global $db;
|
||||
if (is_object($db)) {
|
||||
$db->close_mysql();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
use XcVm\Core\Config\ConfigReader;
|
||||
|
||||
/**
|
||||
* Register the per-install server config (config.ini via ConfigReader) in the
|
||||
* container.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class ConfigStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
$state->container->set('config', ConfigReader::getAll());
|
||||
$state->configLoaded = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
use XcVm\Core\Logging\Logger;
|
||||
|
||||
/**
|
||||
* Load constants (paths, app-config, binaries, error functions) and start the
|
||||
* Logger. The prelude shims delegate to ConstantsInitializer; requiring them by
|
||||
* name keeps the legacy include points working.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class ConstantsStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
if ($state->constantsLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
require_once MAIN_HOME . 'Core/Error/ErrorCodes.php';
|
||||
require_once MAIN_HOME . 'Core/Error/ErrorHandler.php';
|
||||
require_once MAIN_HOME . 'Core/Config/Paths.php';
|
||||
require_once MAIN_HOME . 'Core/Config/AppConfig.php';
|
||||
require_once MAIN_HOME . 'Core/Config/Binaries.php';
|
||||
|
||||
$state->devMode = DEV_MODE;
|
||||
|
||||
if (!defined('PHP_ERRORS')) {
|
||||
define('PHP_ERRORS', $state->devMode);
|
||||
}
|
||||
|
||||
Logger::init(
|
||||
$state->devMode || PHP_ERRORS,
|
||||
LOGS_TMP_PATH . 'error_log.log'
|
||||
);
|
||||
|
||||
$state->constantsLoaded = true;
|
||||
$state->configLoaded = true;
|
||||
$state->loggerStarted = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
use XcVm\Core\Config\SettingsManager;
|
||||
use XcVm\Core\Events\EventDispatcher;
|
||||
use XcVm\Core\Localization\Translator;
|
||||
use XcVm\Domain\Bouquet\BouquetService;
|
||||
use XcVm\Domain\Server\ServerRepository;
|
||||
use XcVm\Domain\Stream\CategoryService;
|
||||
use XcVm\Infrastructure\Bootstrap\DomainDatabaseWiring;
|
||||
use XcVm\Infrastructure\Redis\RedisManager;
|
||||
|
||||
/**
|
||||
* Register the initialized services in the container. Runs last (after all
|
||||
* subsystems), reading BootState readiness flags to decide what to publish:
|
||||
* db, settings, servers, bouquets, categories, redis, translator, events.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class ContainerPopulateStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
$container = $state->container;
|
||||
|
||||
// Database
|
||||
if ($state->databaseReady) {
|
||||
global $db;
|
||||
$container->set('db', $db);
|
||||
DomainDatabaseWiring::wire($db);
|
||||
}
|
||||
|
||||
// Settings and core data
|
||||
if ($state->coreReady) {
|
||||
$container->set('settings', SettingsManager::getAll());
|
||||
$container->set('servers', ServerRepository::getAll());
|
||||
$container->set('bouquets', BouquetService::getAll());
|
||||
$container->set('categories', CategoryService::getFromDatabase());
|
||||
|
||||
if ($state->redisReady && RedisManager::isConnected()) {
|
||||
$container->set('redis', RedisManager::instance());
|
||||
}
|
||||
}
|
||||
|
||||
// Translator
|
||||
if (class_exists(Translator::class, false) && Translator::available()) {
|
||||
$container->set('translator', Translator::class);
|
||||
}
|
||||
|
||||
// Events — create an instance, wire it as the static singleton bridge,
|
||||
// and register it in the container so it can be injected via DI.
|
||||
$dispatcher = new EventDispatcher();
|
||||
EventDispatcher::setInstance($dispatcher);
|
||||
$container->set('events', $dispatcher);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
use XcVm\Core\Database\DatabaseHandler;
|
||||
|
||||
/**
|
||||
* Open the MySQL/MariaDB connection and publish it as the global $db (backward
|
||||
* compatibility) and on the BootState.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class DatabaseStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
if ($state->databaseReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$db = new DatabaseHandler();
|
||||
|
||||
$state->db = $db;
|
||||
$state->databaseReady = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
|
||||
/**
|
||||
* Block banned IPs early. HTTP contexts only — self-skips on CLI.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class FloodProtectionStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
if ($state->isCli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rIP = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
if (!empty($rIP) && file_exists(FLOOD_TMP_PATH . 'block_' . $rIP)) {
|
||||
http_response_code(403);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use RuntimeException;
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
|
||||
/**
|
||||
* Verify the services that should have been registered actually are, failing
|
||||
* loudly instead of degrading silently. Not run for the Minimal context.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class HealthCheckStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
$container = $state->container;
|
||||
|
||||
$required = ['events'];
|
||||
|
||||
if ($state->databaseReady) {
|
||||
$required[] = 'db';
|
||||
}
|
||||
|
||||
if ($state->redisReady) {
|
||||
$required[] = 'redis';
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
foreach ($required as $service) {
|
||||
if (!$container->has($service)) {
|
||||
$missing[] = $service;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing !== []) {
|
||||
throw new RuntimeException(
|
||||
'ServiceContainer health check failed — missing required services: '
|
||||
. implode(', ', $missing)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
|
||||
/**
|
||||
* Ensure the request arrives on an allowed domain (when verify_host is set in
|
||||
* the settings cache). HTTP contexts only — self-skips on CLI.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class HostVerificationStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
if ($state->isCli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defined('HOST')) {
|
||||
$host = trim(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
|
||||
define('HOST', $host);
|
||||
}
|
||||
|
||||
// Domain check via settings cache
|
||||
if (file_exists(CACHE_TMP_PATH . 'settings')) {
|
||||
$rData = @file_get_contents(CACHE_TMP_PATH . 'settings');
|
||||
if ($rData !== false) {
|
||||
$rSettings = @igbinary_unserialize($rData);
|
||||
if (is_array($rSettings) && !empty($rSettings['verify_host'])) {
|
||||
if (file_exists(CACHE_TMP_PATH . 'allowed_domains')) {
|
||||
$rDomains = @igbinary_unserialize(@file_get_contents(CACHE_TMP_PATH . 'allowed_domains'));
|
||||
if (
|
||||
is_array($rDomains) && count($rDomains) > 0
|
||||
&& !in_array(HOST, $rDomains) && HOST !== 'xc_vm'
|
||||
&& !filter_var(HOST, FILTER_VALIDATE_IP)
|
||||
) {
|
||||
\generateError('INVALID_HOST');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
use XcVm\Core\Config\SettingsManager;
|
||||
use XcVm\Core\Database\DatabaseHandler;
|
||||
use XcVm\Core\Init\LegacyInitializer;
|
||||
use XcVm\Infrastructure\Bootstrap\DomainDatabaseWiring;
|
||||
use XcVm\Infrastructure\Database\DatabaseFactory;
|
||||
|
||||
/**
|
||||
* Initialize the legacy core: sanitize superglobals, parse config, define
|
||||
* SERVER_ID, load settings (DB or cache) and wire $db into the domain services.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class LegacyCoreStage implements BootStageInterface {
|
||||
/**
|
||||
* @param bool $cached Load settings from the file cache instead of SQL
|
||||
* (for high-load paths).
|
||||
*/
|
||||
public function __construct(private bool $cached = false) {
|
||||
}
|
||||
|
||||
public function run(BootState $state): void {
|
||||
if ($state->coreReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
DatabaseFactory::set($db);
|
||||
|
||||
// Wire $db into the domain service classes before initCore() runs,
|
||||
// since initCore() calls ServerRepository::getAll() which requires it.
|
||||
DomainDatabaseWiring::wire($db);
|
||||
|
||||
LegacyInitializer::initCore($this->cached);
|
||||
|
||||
// If cache was used and is incomplete — reconnect to DB
|
||||
if ($this->cached && !SettingsManager::get('enable_cache')) {
|
||||
$db = new DatabaseHandler();
|
||||
DatabaseFactory::set($db);
|
||||
DomainDatabaseWiring::wire($db);
|
||||
}
|
||||
|
||||
$state->db = $db;
|
||||
$state->coreReady = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
|
||||
/**
|
||||
* Set the CLI process title (for cron/worker identification).
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class ProcessTitleStage implements BootStageInterface {
|
||||
public function __construct(private string $process) {
|
||||
}
|
||||
|
||||
public function run(BootState $state): void {
|
||||
if ($this->process !== '') {
|
||||
cli_set_process_title($this->process);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
use XcVm\Infrastructure\Redis\RedisManager;
|
||||
|
||||
/**
|
||||
* Connect to Redis. redisReady means "connected", not "attempted": a failed
|
||||
* connection leaves it false so HealthCheckStage does not require the service
|
||||
* and the panel degrades instead of 500ing.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class RedisStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
if ($state->redisReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state->redisReady = RedisManager::ensureConnected();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
|
||||
/**
|
||||
* Start the PHP session with secure cookie parameters. HTTP contexts only —
|
||||
* self-skips on CLI.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class SessionStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
if ($state->sessionStarted || $state->isCli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$rParams = session_get_cookie_params();
|
||||
$rParams['samesite'] = 'Strict';
|
||||
// The panel's scripts never read the session cookie, so an XSS must
|
||||
// not be able to either.
|
||||
$rParams['httponly'] = true;
|
||||
session_set_cookie_params($rParams);
|
||||
// Refuse session ids this server never issued, so a visitor cannot
|
||||
// arrive carrying one an attacker chose.
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
session_start();
|
||||
}
|
||||
|
||||
$state->sessionStarted = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
use XcVm\Core\Config\ConstantsInitializer;
|
||||
|
||||
/**
|
||||
* Define the STATUS_* result constants used by the admin/reseller API handlers.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class StatusConstantsStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
ConstantsInitializer::initStatus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap\Stage;
|
||||
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
use XcVm\Core\Localization\Translator;
|
||||
|
||||
/**
|
||||
* Initialize the i18n Translator.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap_Stage
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class TranslatorStage implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
Translator::init();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace XcVm\Core\Bootstrap;
|
||||
|
||||
use XcVm\Core\Bootstrap\Stage\AdminApiStage;
|
||||
use XcVm\Core\Bootstrap\Stage\AdminGlobalsStage;
|
||||
use XcVm\Core\Bootstrap\Stage\AdminShutdownStage;
|
||||
use XcVm\Core\Bootstrap\Stage\ConfigStage;
|
||||
use XcVm\Core\Bootstrap\Stage\ConstantsStage;
|
||||
use XcVm\Core\Bootstrap\Stage\ContainerPopulateStage;
|
||||
use XcVm\Core\Bootstrap\Stage\DatabaseStage;
|
||||
use XcVm\Core\Bootstrap\Stage\FloodProtectionStage;
|
||||
use XcVm\Core\Bootstrap\Stage\HealthCheckStage;
|
||||
use XcVm\Core\Bootstrap\Stage\HostVerificationStage;
|
||||
use XcVm\Core\Bootstrap\Stage\LegacyCoreStage;
|
||||
use XcVm\Core\Bootstrap\Stage\ProcessTitleStage;
|
||||
use XcVm\Core\Bootstrap\Stage\RedisStage;
|
||||
use XcVm\Core\Bootstrap\Stage\SessionStage;
|
||||
use XcVm\Core\Bootstrap\Stage\StatusConstantsStage;
|
||||
use XcVm\Core\Bootstrap\Stage\TranslatorStage;
|
||||
use XcVm\Core\Enum\BootContext;
|
||||
|
||||
/**
|
||||
* Builds the ordered stage list for a boot context.
|
||||
*
|
||||
* This is where the previously separate boot paths converge on one stage set:
|
||||
* each context selects a different ordered list, mirroring the exact sequence
|
||||
* the old XC_Bootstrap ran for that context. Flood/host/session stages self-skip
|
||||
* on CLI, so they are safe to include unconditionally.
|
||||
*
|
||||
* @package XC_VM_Core_Bootstrap
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @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
|
||||
*/
|
||||
class StageProfiles {
|
||||
/**
|
||||
* @param array<string,mixed> $options Resolved boot options.
|
||||
* @return list<BootStageInterface>
|
||||
*/
|
||||
public static function for(BootContext $context, array $options): array {
|
||||
// Common prefix, run for every context (HTTP-only stages self-skip on CLI).
|
||||
$stages = [
|
||||
new ConstantsStage(),
|
||||
new ConfigStage(),
|
||||
new FloodProtectionStage(),
|
||||
new HostVerificationStage(),
|
||||
];
|
||||
|
||||
switch ($context) {
|
||||
case BootContext::Minimal:
|
||||
break;
|
||||
|
||||
case BootContext::Cli:
|
||||
$stages[] = new DatabaseStage();
|
||||
$stages[] = new LegacyCoreStage((bool) ($options['cached'] ?? false));
|
||||
if (!empty($options['redis'])) {
|
||||
$stages[] = new RedisStage();
|
||||
}
|
||||
if (!empty($options['process'])) {
|
||||
$stages[] = new ProcessTitleStage((string) $options['process']);
|
||||
}
|
||||
break;
|
||||
|
||||
case BootContext::Stream:
|
||||
$stages[] = new DatabaseStage();
|
||||
break;
|
||||
|
||||
case BootContext::Admin:
|
||||
$stages[] = new SessionStage();
|
||||
$stages[] = new DatabaseStage();
|
||||
$stages[] = new LegacyCoreStage(false);
|
||||
$stages[] = new RedisStage();
|
||||
$stages[] = new AdminApiStage();
|
||||
$stages[] = new TranslatorStage();
|
||||
$stages[] = new AdminShutdownStage();
|
||||
$stages[] = new StatusConstantsStage();
|
||||
$stages[] = new AdminGlobalsStage();
|
||||
break;
|
||||
}
|
||||
|
||||
$stages[] = new ContainerPopulateStage();
|
||||
|
||||
if ($context !== BootContext::Minimal) {
|
||||
$stages[] = new HealthCheckStage();
|
||||
}
|
||||
|
||||
return $stages;
|
||||
}
|
||||
}
|
||||
@@ -65,4 +65,15 @@ class DatabaseFactory {
|
||||
}
|
||||
self::$instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the singleton registry without closing the connection.
|
||||
*
|
||||
* Test seam for deterministic per-test state: unlike close(), it does not
|
||||
* touch the global $db or call close_mysql(), so it is safe with the fake
|
||||
* DatabaseHandler subtypes tests inject.
|
||||
*/
|
||||
public static function reset(): void {
|
||||
self::$instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-589
@@ -46,6 +46,9 @@
|
||||
* require_once '/home/xc_vm/bootstrap.php';
|
||||
* XC_Bootstrap::boot(XC_Bootstrap::CONTEXT_MINIMAL);
|
||||
*
|
||||
* The heavy lifting now lives in XcVm\Core\Bootstrap\BootKernel and its stages;
|
||||
* XC_Bootstrap is a thin, backward-compatible static facade over that pipeline.
|
||||
*
|
||||
* @package XC_VM
|
||||
* @author Divarion_D <https://github.com/Divarion-D>
|
||||
* @copyright 2025-2026 Vateron Media
|
||||
@@ -53,25 +56,14 @@
|
||||
* @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html
|
||||
*/
|
||||
|
||||
use XcVm\Core\Config\ConfigReader;
|
||||
use XcVm\Core\Bootstrap\BootKernel;
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Config\ConstantsInitializer;
|
||||
use XcVm\Core\Config\SettingsManager;
|
||||
use XcVm\Core\Container\ServiceContainer;
|
||||
use XcVm\Core\Database\Database;
|
||||
use XcVm\Core\Database\DatabaseHandler;
|
||||
use XcVm\Core\Enum\BootContext;
|
||||
use XcVm\Core\Events\EventDispatcher;
|
||||
use XcVm\Core\Init\LegacyInitializer;
|
||||
use XcVm\Core\Localization\Translator;
|
||||
use XcVm\Core\Logging\Logger;
|
||||
use XcVm\Domain\Bouquet\BouquetService;
|
||||
use XcVm\Domain\Server\ServerRepository;
|
||||
use XcVm\Domain\Stream\CategoryService;
|
||||
use XcVm\Domain\User\ResellerAPI;
|
||||
use XcVm\Domain\User\UserRepository;
|
||||
use XcVm\Infrastructure\Bootstrap\DomainDatabaseWiring;
|
||||
use XcVm\Infrastructure\Database\DatabaseFactory;
|
||||
use XcVm\Infrastructure\Redis\RedisManager;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 1. Class autoloader
|
||||
@@ -114,7 +106,7 @@ if (!function_exists('getallheaders')) {
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 3. XC_Bootstrap class
|
||||
// 3. XC_Bootstrap facade
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class XC_Bootstrap {
|
||||
@@ -128,34 +120,11 @@ class XC_Bootstrap {
|
||||
/** @deprecated Use BootContext::Admin */
|
||||
const CONTEXT_ADMIN = 'admin';
|
||||
|
||||
// ── Internal state ───────────────────────────────────────
|
||||
private static bool $booted = false;
|
||||
|
||||
private static ?string $context = null;
|
||||
|
||||
private static array $options = [];
|
||||
|
||||
private static bool $devMode = false;
|
||||
|
||||
// ── Subsystem initialization flags ────────────────────────
|
||||
private static bool $constantsLoaded = false;
|
||||
|
||||
private static bool $configLoaded = false;
|
||||
|
||||
private static bool $loggerStarted = false;
|
||||
|
||||
private static bool $databaseReady = false;
|
||||
|
||||
private static bool $coreReady = false;
|
||||
|
||||
private static bool $adminReady = false;
|
||||
|
||||
private static bool $sessionStarted = false;
|
||||
|
||||
private static bool $redisReady = false;
|
||||
/** Result of the last boot(); null until the first boot. */
|
||||
private static ?BootState $state = null;
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
* Main entry point — delegates to the BootKernel pipeline.
|
||||
*
|
||||
* @param string|BootContext $context Boot context. Accepts BootContext enum or legacy string constant.
|
||||
* @param array $options Additional options:
|
||||
@@ -165,68 +134,27 @@ class XC_Bootstrap {
|
||||
* 'shutdown' => callable Shutdown callback (replaces register_shutdown_function)
|
||||
*/
|
||||
public static function boot(string|BootContext $context = BootContext::Cli, array $options = []): void {
|
||||
if (self::$booted) {
|
||||
if (self::$state?->booted) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ctx = $context instanceof BootContext ? $context : BootContext::from($context);
|
||||
|
||||
self::$context = $ctx->value;
|
||||
self::$options = array_merge(self::defaults($ctx), $options);
|
||||
|
||||
// ── Create container ────────────────────────────────────
|
||||
$container = ServiceContainer::getInstance();
|
||||
$container->set('context', $ctx->value);
|
||||
$container->set('options', self::$options);
|
||||
|
||||
// ── Common for all contexts ────────────────────────────
|
||||
|
||||
self::loadConstants();
|
||||
|
||||
$container->set('config', ConfigReader::getAll());
|
||||
|
||||
// ── Flood-protection (HTTP only) ───────────────────────
|
||||
if (!self::isCli()) {
|
||||
self::floodProtection();
|
||||
self::hostVerification();
|
||||
}
|
||||
|
||||
// ── Context-dependent initialization ───────────────────
|
||||
|
||||
match ($ctx) {
|
||||
BootContext::Minimal => null,
|
||||
BootContext::Cli => self::bootCli(),
|
||||
BootContext::Stream => self::bootStream(),
|
||||
BootContext::Admin => self::bootAdmin(),
|
||||
};
|
||||
|
||||
// ── Register services in the container ──────────────────
|
||||
self::populateContainer($container);
|
||||
|
||||
// ── Verify that expected services were registered ────────
|
||||
if ($ctx !== BootContext::Minimal) {
|
||||
self::assertContainerHealth($container);
|
||||
}
|
||||
|
||||
self::$booted = true;
|
||||
self::$state = (new BootKernel())->boot($ctx, $options);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Public getters
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Current boot context.
|
||||
*/
|
||||
public static function getContext(): ?string {
|
||||
return self::$context;
|
||||
return self::$state?->context->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether bootstrap has been executed.
|
||||
*/
|
||||
public static function isBooted(): bool {
|
||||
return self::$booted;
|
||||
return (bool) self::$state?->booted;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +162,7 @@ class XC_Bootstrap {
|
||||
* When true, PHP errors are displayed on-screen regardless of DB settings.
|
||||
*/
|
||||
public static function isDevMode(): bool {
|
||||
return self::$devMode;
|
||||
return (bool) self::$state?->devMode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,8 +175,6 @@ class XC_Bootstrap {
|
||||
|
||||
/**
|
||||
* Get the ServiceContainer.
|
||||
*
|
||||
* @return ServiceContainer
|
||||
*/
|
||||
public static function getContainer(): ServiceContainer {
|
||||
return ServiceContainer::getInstance();
|
||||
@@ -262,516 +188,22 @@ class XC_Bootstrap {
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reset (for testing).
|
||||
* Force reset (for testing): drops the boot state and the process-wide
|
||||
* singletons the pipeline populates.
|
||||
*/
|
||||
public static function reset(): void {
|
||||
self::$booted = false;
|
||||
self::$context = null;
|
||||
self::$options = [];
|
||||
self::$constantsLoaded = false;
|
||||
self::$configLoaded = false;
|
||||
self::$loggerStarted = false;
|
||||
self::$databaseReady = false;
|
||||
self::$coreReady = false;
|
||||
self::$adminReady = false;
|
||||
self::$sessionStarted = false;
|
||||
self::$redisReady = false;
|
||||
self::$state = null;
|
||||
|
||||
ServiceContainer::resetInstance();
|
||||
EventDispatcher::resetInstance();
|
||||
DatabaseFactory::reset();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Context boot sequences
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Boot sequence for the CLI context: database, legacy core, optional redis
|
||||
* and process title.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function bootCli(): void {
|
||||
self::initDatabase(self::$options['cached']);
|
||||
self::initLegacyCore(self::$options['cached']);
|
||||
if (self::$options['redis']) {
|
||||
self::initRedis();
|
||||
}
|
||||
if (!empty(self::$options['process'])) {
|
||||
cli_set_process_title(self::$options['process']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot sequence for the streaming context: cached database connection only.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function bootStream(): void {
|
||||
self::initDatabase(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot sequence for the admin context: session, database, legacy core, redis,
|
||||
* admin API, translator, shutdown handler and status constants.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function bootAdmin(): void {
|
||||
self::initSession();
|
||||
self::initDatabase(false);
|
||||
self::initLegacyCore(false);
|
||||
self::initRedis();
|
||||
self::initAdminAPI();
|
||||
self::initTranslator();
|
||||
self::registerAdminShutdown();
|
||||
self::defineStatusConstants();
|
||||
self::initAdminGlobals();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Subsystem initialization (each called at most once)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load constants, paths, Logger, error functions.
|
||||
*
|
||||
* Loads core configuration directly (without www/constants.php):
|
||||
* core/Error/ErrorCodes.php — $rErrorCodes
|
||||
* core/Error/ErrorHandler.php — generateError(), generate404()
|
||||
* core/Config/Paths.php — *_PATH constants
|
||||
* core/Config/AppConfig.php — version, Git, flags
|
||||
* core/Config/Binaries.php — FFMPEG, FFPROBE, GeoIP
|
||||
*/
|
||||
private static function loadConstants(): void {
|
||||
if (self::$constantsLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
require_once MAIN_HOME . 'Core/Error/ErrorCodes.php';
|
||||
require_once MAIN_HOME . 'Core/Error/ErrorHandler.php';
|
||||
require_once MAIN_HOME . 'Core/Config/Paths.php';
|
||||
require_once MAIN_HOME . 'Core/Config/AppConfig.php';
|
||||
require_once MAIN_HOME . 'Core/Config/Binaries.php';
|
||||
|
||||
self::$devMode = DEV_MODE;
|
||||
|
||||
if (!defined('PHP_ERRORS')) {
|
||||
define('PHP_ERRORS', self::$devMode);
|
||||
}
|
||||
|
||||
Logger::init(
|
||||
self::$devMode || PHP_ERRORS,
|
||||
LOGS_TMP_PATH . 'error_log.log'
|
||||
);
|
||||
|
||||
self::$constantsLoaded = true;
|
||||
self::$configLoaded = true;
|
||||
self::$loggerStarted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flood-protection: block banned IPs.
|
||||
*
|
||||
* Called for HTTP contexts only.
|
||||
*/
|
||||
private static function floodProtection(): void {
|
||||
if (self::isCli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rIP = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
if (!empty($rIP) && file_exists(FLOOD_TMP_PATH . 'block_' . $rIP)) {
|
||||
http_response_code(403);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host verification: ensure request comes from an allowed domain.
|
||||
*/
|
||||
private static function hostVerification(): void {
|
||||
if (self::isCli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defined('HOST')) {
|
||||
$host = trim(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
|
||||
define('HOST', $host);
|
||||
}
|
||||
|
||||
// Domain check via settings cache
|
||||
if (file_exists(CACHE_TMP_PATH . 'settings')) {
|
||||
$rData = @file_get_contents(CACHE_TMP_PATH . 'settings');
|
||||
if ($rData !== false) {
|
||||
$rSettings = @igbinary_unserialize($rData);
|
||||
if (is_array($rSettings) && !empty($rSettings['verify_host'])) {
|
||||
if (file_exists(CACHE_TMP_PATH . 'allowed_domains')) {
|
||||
$rDomains = @igbinary_unserialize(@file_get_contents(CACHE_TMP_PATH . 'allowed_domains'));
|
||||
if (
|
||||
is_array($rDomains) && count($rDomains) > 0
|
||||
&& !in_array(HOST, $rDomains) && HOST !== 'xc_vm'
|
||||
&& !filter_var(HOST, FILTER_VALIDATE_IP)
|
||||
) {
|
||||
generateError('INVALID_HOST');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start PHP session with secure parameters.
|
||||
*
|
||||
* HTTP contexts only (admin/reseller).
|
||||
*/
|
||||
private static function initSession(): void {
|
||||
if (self::$sessionStarted || self::isCli()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$rParams = session_get_cookie_params() ?: [];
|
||||
$rParams['samesite'] = 'Strict';
|
||||
// The panel's scripts never read the session cookie, so an XSS must
|
||||
// not be able to either.
|
||||
$rParams['httponly'] = true;
|
||||
session_set_cookie_params($rParams);
|
||||
// Refuse session ids this server never issued, so a visitor cannot
|
||||
// arrive carrying one an attacker chose.
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
session_start();
|
||||
}
|
||||
|
||||
self::$sessionStarted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to MySQL/MariaDB.
|
||||
*
|
||||
* Creates the global $db variable (backward compatibility).
|
||||
*
|
||||
* @param bool $cached If true, LegacyInitializer will use
|
||||
* file cache instead of SQL queries for settings.
|
||||
*/
|
||||
private static function initDatabase(bool $cached = false): void {
|
||||
if (self::$databaseReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$db = new DatabaseHandler();
|
||||
|
||||
self::$databaseReady = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize legacy core subsystems.
|
||||
*
|
||||
* Sanitizes globals ($_GET, $_POST, $_SESSION, $_COOKIE),
|
||||
* parses config, defines SERVER_ID, selects FFmpeg binaries,
|
||||
* loads settings (from DB or cache).
|
||||
*
|
||||
* @param bool $cached Use cache for settings (for high-load paths)
|
||||
*/
|
||||
private static function initLegacyCore(bool $cached = false): void {
|
||||
if (self::$coreReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
DatabaseFactory::set($db);
|
||||
|
||||
// Wire $db into the domain service classes before initCore() runs,
|
||||
// since initCore() calls ServerRepository::getAll() which requires it.
|
||||
self::wireDomainDatabase($db);
|
||||
|
||||
LegacyInitializer::initCore($cached);
|
||||
|
||||
// If cache was used and is incomplete — reconnect to DB
|
||||
if ($cached && !SettingsManager::get('enable_cache')) {
|
||||
$db = new DatabaseHandler();
|
||||
DatabaseFactory::set($db);
|
||||
self::wireDomainDatabase($db);
|
||||
}
|
||||
|
||||
self::$coreReady = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Redis.
|
||||
*/
|
||||
private static function initRedis(): void {
|
||||
if (self::$redisReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
// $redisReady means "connected", not "attempted": assertContainerHealth()
|
||||
// requires the 'redis' service only when this flag is set, so a failed
|
||||
// connection must leave it false — the panel degrades instead of 500ing.
|
||||
self::$redisReady = RedisManager::ensureConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Admin API + Reseller API.
|
||||
*
|
||||
* Initializes ResellerAPI class and admin user info.
|
||||
*/
|
||||
private static function initAdminAPI(): void {
|
||||
if (self::$adminReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
|
||||
// Admin user info
|
||||
if (isset($_SESSION['hash'])) {
|
||||
$GLOBALS['rAdminUserInfo'] = UserRepository::getRegisteredUserById($_SESSION['hash']);
|
||||
}
|
||||
|
||||
ResellerAPI::init();
|
||||
|
||||
self::$adminReady = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Translator (i18n).
|
||||
*/
|
||||
private static function initTranslator(): void {
|
||||
Translator::init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register shutdown function for admin context.
|
||||
*
|
||||
* Closes the MySQL connection on script termination.
|
||||
*/
|
||||
private static function registerAdminShutdown(): void {
|
||||
register_shutdown_function(function () {
|
||||
global $db;
|
||||
if (is_object($db)) {
|
||||
$db->close_mysql();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the container with initialized services.
|
||||
*
|
||||
* Called at the end of boot() — all subsystems are already running,
|
||||
* so it is safe to reference $db, SettingsManager, etc.
|
||||
*
|
||||
* Container stores:
|
||||
* 'db' => Database — PDO wrapper
|
||||
* 'config' => array — $_INFO from config.ini
|
||||
* 'settings' => array — panel settings
|
||||
* 'redis' => Redis|null — Redis connection
|
||||
* 'servers' => array — server list
|
||||
* 'bouquets' => array — bouquets
|
||||
* 'categories' => array — categories
|
||||
* 'translator' => string — Translator class
|
||||
* 'events' => string — EventDispatcher class
|
||||
*
|
||||
*/
|
||||
private static function populateContainer(ServiceContainer $container): void {
|
||||
// Database
|
||||
if (self::$databaseReady) {
|
||||
global $db;
|
||||
$container->set('db', $db);
|
||||
self::wireDomainDatabase($db);
|
||||
}
|
||||
|
||||
// Settings and core data
|
||||
if (self::$coreReady) {
|
||||
$container->set('settings', SettingsManager::getAll());
|
||||
$container->set('servers', ServerRepository::getAll());
|
||||
$container->set('bouquets', BouquetService::getAll());
|
||||
$container->set('categories', CategoryService::getFromDatabase());
|
||||
|
||||
if (self::$redisReady && RedisManager::isConnected()) {
|
||||
$container->set('redis', RedisManager::instance());
|
||||
}
|
||||
}
|
||||
|
||||
// Translator
|
||||
if (class_exists(Translator::class, false) && Translator::available()) {
|
||||
$container->set('translator', Translator::class);
|
||||
}
|
||||
|
||||
// Events — create an instance, wire it as the static singleton bridge,
|
||||
// and register it in the container so it can be injected via DI.
|
||||
$dispatcher = new EventDispatcher();
|
||||
EventDispatcher::setInstance($dispatcher);
|
||||
$container->set('events', $dispatcher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the injected $db instance into every domain service class.
|
||||
*
|
||||
* Domain classes use the static setDb() / db() pattern: setDb() stores
|
||||
* the injected instance; db() returns it. Calling this method removes the
|
||||
* need for the global $db fallback inside each db() helper.
|
||||
*
|
||||
* @param DatabaseHandler $db DatabaseHandler instance
|
||||
*/
|
||||
private static function wireDomainDatabase(object $db): void {
|
||||
DomainDatabaseWiring::wire($db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that services which should have been registered actually are.
|
||||
*
|
||||
* Called after populateContainer() for every context except CONTEXT_MINIMAL.
|
||||
* Throws RuntimeException on the first missing service so the application
|
||||
* fails loudly instead of silently degrading.
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private static function assertContainerHealth(ServiceContainer $container): void {
|
||||
$required = ['events'];
|
||||
|
||||
if (self::$databaseReady) {
|
||||
$required[] = 'db';
|
||||
}
|
||||
|
||||
if (self::$redisReady) {
|
||||
$required[] = 'redis';
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
foreach ($required as $service) {
|
||||
if (!$container->has($service)) {
|
||||
$missing[] = $service;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing !== []) {
|
||||
throw new RuntimeException(
|
||||
'ServiceContainer health check failed — missing required services: '
|
||||
. implode(', ', $missing)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default boot options for a context.
|
||||
*
|
||||
* @param BootContext $ctx Boot context.
|
||||
* @return array Options: cached, redis, process, shutdown.
|
||||
*/
|
||||
private static function defaults(BootContext $ctx): array {
|
||||
return match ($ctx) {
|
||||
BootContext::Admin => ['cached' => false, 'redis' => true, 'process' => '', 'shutdown' => null],
|
||||
BootContext::Stream => ['cached' => true, 'redis' => false, 'process' => '', 'shutdown' => null],
|
||||
BootContext::Cli => ['cached' => false, 'redis' => false, 'process' => '', 'shutdown' => null],
|
||||
BootContext::Minimal => ['cached' => false, 'redis' => false, 'process' => '', 'shutdown' => null],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize admin globals: MobileDetect, timeouts, servers, protocol.
|
||||
*/
|
||||
private static function initAdminGlobals(): void {
|
||||
global $rDetect, $rMobile, $rTimeout, $rSQLTimeout, $rProtocol,
|
||||
$allServers, $rServers, $rSettings, $rProxyServers,
|
||||
$rPermissions, $allowedLangs;
|
||||
|
||||
if (!defined('SERVER_ID')) {
|
||||
define('SERVER_ID', intval(ConfigReader::get('server_id')));
|
||||
}
|
||||
|
||||
$rDetect = new \Detection\MobileDetect();
|
||||
$rMobile = $rDetect->isMobile();
|
||||
|
||||
$rTimeout = 15;
|
||||
$rSQLTimeout = 10;
|
||||
set_time_limit($rTimeout);
|
||||
ini_set('mysql.connect_timeout', (string) $rSQLTimeout);
|
||||
ini_set('max_execution_time', (string) $rTimeout);
|
||||
ini_set('default_socket_timeout', (string) $rTimeout);
|
||||
|
||||
$rProtocol = self::detectProtocol();
|
||||
$allServers = ServerRepository::getAllSimple();
|
||||
$rServers = ServerRepository::getStreamingSimple($rPermissions);
|
||||
$rSettings = SettingsManager::getAll();
|
||||
if (self::$devMode) {
|
||||
$rSettings['debug_show_errors'] = true;
|
||||
}
|
||||
$rProxyServers = ServerRepository::getProxySimple($rPermissions);
|
||||
|
||||
$allowedLangs = Translator::available();
|
||||
|
||||
// Sort servers by order
|
||||
if (is_array($rServers)) {
|
||||
uasort(
|
||||
$rServers,
|
||||
function ($a, $b) {
|
||||
return $a['order'] - $b['order'];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure the legacy 'reseller' assets alias (Public/assets/reseller → admin).
|
||||
self::ensureResellerAssetsSymlink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the legacy 'reseller' assets alias exists (Public/assets/reseller → admin).
|
||||
*
|
||||
* Reseller pages reuse the admin asset bundle, and some nginx configs / legacy
|
||||
* routes expect Public/assets/reseller to resolve to Public/assets/admin. The
|
||||
* link is not stored in git (an absolute-path symlink broke the build), so the
|
||||
* panel recreates it here. Idempotent, and repairs a stale/broken link.
|
||||
*/
|
||||
private static function ensureResellerAssetsSymlink(): void {
|
||||
$assetsBase = MAIN_HOME . 'Public/assets/';
|
||||
$resellerLink = $assetsBase . 'reseller';
|
||||
|
||||
// Nothing to point at yet — skip.
|
||||
if (!is_dir($assetsBase . 'admin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_link($resellerLink)) {
|
||||
// Correct, resolvable link → nothing to do.
|
||||
if (readlink($resellerLink) === 'admin' && is_dir($resellerLink)) {
|
||||
return;
|
||||
}
|
||||
@unlink($resellerLink); // stale/broken link — recreate below
|
||||
} elseif (file_exists($resellerLink)) {
|
||||
return; // a real directory/file lives here — leave it alone
|
||||
}
|
||||
|
||||
// Relative link so it is independent of MAIN_HOME and path case.
|
||||
if (!@symlink('admin', $resellerLink)) {
|
||||
// Without the link every reseller access code serves pages with no
|
||||
// CSS/JS (nginx aliases /CODE/assets/ to Public/assets/reseller/).
|
||||
error_log('XC_Bootstrap: failed to create Public/assets/reseller -> admin symlink — check ownership of Public/assets/ (expected xc_vm).');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect HTTP protocol (http/https).
|
||||
*/
|
||||
private static function detectProtocol(): string {
|
||||
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
$port443 = isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443;
|
||||
return ($https || $port443) ? 'https' : 'http';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Status constants (from admin.php)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Define status constants (STATUS_FAILURE, STATUS_SUCCESS, ...).
|
||||
*
|
||||
* Used throughout admin and reseller API handlers.
|
||||
* Called automatically in CONTEXT_ADMIN.
|
||||
* Can be called manually when needed.
|
||||
* Used throughout admin and reseller API handlers. Called automatically in
|
||||
* the Admin context; can be called manually when needed.
|
||||
*/
|
||||
public static function defineStatusConstants(): void {
|
||||
ConstantsInitializer::initStatus();
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use XcVm\Core\Bootstrap\BootKernel;
|
||||
use XcVm\Core\Bootstrap\BootPipeline;
|
||||
use XcVm\Core\Bootstrap\BootStageInterface;
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\StageProfiles;
|
||||
use XcVm\Core\Bootstrap\Stage\AdminApiStage;
|
||||
use XcVm\Core\Bootstrap\Stage\AdminGlobalsStage;
|
||||
use XcVm\Core\Bootstrap\Stage\AdminShutdownStage;
|
||||
use XcVm\Core\Bootstrap\Stage\ConfigStage;
|
||||
use XcVm\Core\Bootstrap\Stage\ConstantsStage;
|
||||
use XcVm\Core\Bootstrap\Stage\ContainerPopulateStage;
|
||||
use XcVm\Core\Bootstrap\Stage\DatabaseStage;
|
||||
use XcVm\Core\Bootstrap\Stage\FloodProtectionStage;
|
||||
use XcVm\Core\Bootstrap\Stage\HealthCheckStage;
|
||||
use XcVm\Core\Bootstrap\Stage\HostVerificationStage;
|
||||
use XcVm\Core\Bootstrap\Stage\LegacyCoreStage;
|
||||
use XcVm\Core\Bootstrap\Stage\ProcessTitleStage;
|
||||
use XcVm\Core\Bootstrap\Stage\RedisStage;
|
||||
use XcVm\Core\Bootstrap\Stage\SessionStage;
|
||||
use XcVm\Core\Bootstrap\Stage\StatusConstantsStage;
|
||||
use XcVm\Core\Bootstrap\Stage\TranslatorStage;
|
||||
use XcVm\Core\Container\ServiceContainer;
|
||||
use XcVm\Core\Enum\BootContext;
|
||||
|
||||
/**
|
||||
* Pipeline mechanics and per-context profile composition. A full boot cannot run
|
||||
* in-process (ConfigStage calls the \XC_VM config extension, absent in tests), so
|
||||
* this covers ordering and composition with fakes/pure inspection.
|
||||
*/
|
||||
final class BootPipelineTest extends TestCase {
|
||||
protected function tearDown(): void {
|
||||
ServiceContainer::resetInstance();
|
||||
}
|
||||
|
||||
private function state(BootContext $ctx = BootContext::Minimal): BootState {
|
||||
return new BootState($ctx, [], ServiceContainer::getInstance());
|
||||
}
|
||||
|
||||
private function recordingStage(string $name, ArrayObject $log): BootStageInterface {
|
||||
return new class ($name, $log) implements BootStageInterface {
|
||||
public function __construct(private string $name, private ArrayObject $log) {
|
||||
}
|
||||
|
||||
public function run(BootState $state): void {
|
||||
$this->log->append($this->name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function testPipelineRunsStagesInOrder(): void {
|
||||
$log = new ArrayObject();
|
||||
|
||||
(new BootPipeline([
|
||||
$this->recordingStage('a', $log),
|
||||
$this->recordingStage('b', $log),
|
||||
$this->recordingStage('c', $log),
|
||||
]))->run($this->state());
|
||||
|
||||
$this->assertSame(['a', 'b', 'c'], $log->getArrayCopy());
|
||||
}
|
||||
|
||||
public function testPipelineAbortsOnThrow(): void {
|
||||
$log = new ArrayObject();
|
||||
$boom = new class implements BootStageInterface {
|
||||
public function run(BootState $state): void {
|
||||
throw new RuntimeException('boom');
|
||||
}
|
||||
};
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
try {
|
||||
(new BootPipeline([$boom, $this->recordingStage('after', $log)]))->run($this->state());
|
||||
} finally {
|
||||
$this->assertNotContains('after', $log->getArrayCopy(), 'stage after a throwing stage must not run');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $options
|
||||
* @return list<class-string>
|
||||
*/
|
||||
private function classesFor(BootContext $ctx, array $options = []): array {
|
||||
$opts = array_merge(BootKernel::defaults($ctx), $options);
|
||||
return array_map('get_class', StageProfiles::for($ctx, $opts));
|
||||
}
|
||||
|
||||
public function testMinimalProfile(): void {
|
||||
$this->assertSame([
|
||||
ConstantsStage::class,
|
||||
ConfigStage::class,
|
||||
FloodProtectionStage::class,
|
||||
HostVerificationStage::class,
|
||||
ContainerPopulateStage::class,
|
||||
], $this->classesFor(BootContext::Minimal));
|
||||
}
|
||||
|
||||
public function testCliProfileDefaultsOmitRedisAndProcessTitle(): void {
|
||||
$this->assertSame([
|
||||
ConstantsStage::class,
|
||||
ConfigStage::class,
|
||||
FloodProtectionStage::class,
|
||||
HostVerificationStage::class,
|
||||
DatabaseStage::class,
|
||||
LegacyCoreStage::class,
|
||||
ContainerPopulateStage::class,
|
||||
HealthCheckStage::class,
|
||||
], $this->classesFor(BootContext::Cli));
|
||||
}
|
||||
|
||||
public function testCliProfileAddsRedisAndProcessTitleWhenRequested(): void {
|
||||
$classes = $this->classesFor(BootContext::Cli, ['redis' => true, 'process' => 'worker']);
|
||||
|
||||
$this->assertContains(RedisStage::class, $classes);
|
||||
$this->assertContains(ProcessTitleStage::class, $classes);
|
||||
}
|
||||
|
||||
public function testStreamProfileIsDatabaseOnly(): void {
|
||||
$this->assertSame([
|
||||
ConstantsStage::class,
|
||||
ConfigStage::class,
|
||||
FloodProtectionStage::class,
|
||||
HostVerificationStage::class,
|
||||
DatabaseStage::class,
|
||||
ContainerPopulateStage::class,
|
||||
HealthCheckStage::class,
|
||||
], $this->classesFor(BootContext::Stream));
|
||||
}
|
||||
|
||||
public function testAdminProfileFullSequence(): void {
|
||||
$this->assertSame([
|
||||
ConstantsStage::class,
|
||||
ConfigStage::class,
|
||||
FloodProtectionStage::class,
|
||||
HostVerificationStage::class,
|
||||
SessionStage::class,
|
||||
DatabaseStage::class,
|
||||
LegacyCoreStage::class,
|
||||
RedisStage::class,
|
||||
AdminApiStage::class,
|
||||
TranslatorStage::class,
|
||||
AdminShutdownStage::class,
|
||||
StatusConstantsStage::class,
|
||||
AdminGlobalsStage::class,
|
||||
ContainerPopulateStage::class,
|
||||
HealthCheckStage::class,
|
||||
], $this->classesFor(BootContext::Admin));
|
||||
}
|
||||
|
||||
public function testMinimalHasNoHealthCheck(): void {
|
||||
$this->assertNotContains(HealthCheckStage::class, $this->classesFor(BootContext::Minimal));
|
||||
}
|
||||
|
||||
public function testDefaultsPerContext(): void {
|
||||
$this->assertTrue(BootKernel::defaults(BootContext::Admin)['redis']);
|
||||
$this->assertFalse(BootKernel::defaults(BootContext::Cli)['redis']);
|
||||
$this->assertTrue(BootKernel::defaults(BootContext::Stream)['cached']);
|
||||
$this->assertFalse(BootKernel::defaults(BootContext::Admin)['cached']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use XcVm\Core\Bootstrap\BootState;
|
||||
use XcVm\Core\Bootstrap\Stage\ContainerPopulateStage;
|
||||
use XcVm\Core\Bootstrap\Stage\HealthCheckStage;
|
||||
use XcVm\Core\Container\ServiceContainer;
|
||||
use XcVm\Core\Enum\BootContext;
|
||||
use XcVm\Core\Events\EventDispatcher;
|
||||
use XcVm\Infrastructure\Database\DatabaseFactory;
|
||||
|
||||
require_once __DIR__ . '/../Support/TestDb.php';
|
||||
|
||||
/**
|
||||
* Isolated coverage for the boot pieces that do not need a DB connection or the
|
||||
* config extension: BootState, ContainerPopulateStage's no-DB path,
|
||||
* HealthCheckStage, and the DatabaseFactory::reset() test seam.
|
||||
*/
|
||||
final class BootStageTest extends TestCase {
|
||||
protected function tearDown(): void {
|
||||
ServiceContainer::resetInstance();
|
||||
EventDispatcher::resetInstance();
|
||||
DatabaseFactory::reset();
|
||||
}
|
||||
|
||||
private function freshState(): BootState {
|
||||
return new BootState(BootContext::Minimal, [], ServiceContainer::getInstance());
|
||||
}
|
||||
|
||||
public function testBootStateReadinessDefaultsFalse(): void {
|
||||
$state = $this->freshState();
|
||||
|
||||
$this->assertFalse($state->booted);
|
||||
$this->assertFalse($state->databaseReady);
|
||||
$this->assertFalse($state->coreReady);
|
||||
$this->assertFalse($state->redisReady);
|
||||
$this->assertNull($state->db);
|
||||
$this->assertSame(BootContext::Minimal, $state->context);
|
||||
}
|
||||
|
||||
public function testBootStateIsCliUnderCliSapi(): void {
|
||||
// The test suite itself runs under the CLI SAPI.
|
||||
$this->assertTrue($this->freshState()->isCli());
|
||||
}
|
||||
|
||||
public function testContainerPopulateAlwaysRegistersEvents(): void {
|
||||
$state = $this->freshState();
|
||||
|
||||
(new ContainerPopulateStage())->run($state);
|
||||
|
||||
$this->assertTrue($state->container->has('events'));
|
||||
$this->assertInstanceOf(EventDispatcher::class, $state->container->get('events'));
|
||||
}
|
||||
|
||||
public function testContainerPopulateSkipsDbWhenNotReady(): void {
|
||||
$state = $this->freshState();
|
||||
|
||||
(new ContainerPopulateStage())->run($state);
|
||||
|
||||
$this->assertFalse($state->container->has('db'));
|
||||
$this->assertFalse($state->container->has('settings'));
|
||||
}
|
||||
|
||||
public function testHealthCheckPassesWhenEventsPresent(): void {
|
||||
$state = $this->freshState();
|
||||
$state->container->set('events', new EventDispatcher());
|
||||
|
||||
(new HealthCheckStage())->run($state);
|
||||
|
||||
$this->assertTrue(true); // reached without throwing
|
||||
}
|
||||
|
||||
public function testHealthCheckThrowsWhenEventsMissing(): void {
|
||||
$state = $this->freshState();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('events');
|
||||
(new HealthCheckStage())->run($state);
|
||||
}
|
||||
|
||||
public function testHealthCheckRequiresDbWhenDatabaseReady(): void {
|
||||
$state = $this->freshState();
|
||||
$state->container->set('events', new EventDispatcher());
|
||||
$state->databaseReady = true;
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('db');
|
||||
(new HealthCheckStage())->run($state);
|
||||
}
|
||||
|
||||
public function testDatabaseFactoryResetClearsRegistry(): void {
|
||||
$db = new TestDb();
|
||||
DatabaseFactory::set($db);
|
||||
$this->assertSame($db, DatabaseFactory::get());
|
||||
|
||||
DatabaseFactory::reset();
|
||||
|
||||
$this->assertNull(DatabaseFactory::get());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user