Files
MageSail/Magesail/Model/TunnelStoreProvisioner.php
T

259 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace MageSail\Magesail\Model;
use Magento\Framework\App\Cache\TypeListInterface;
use Magento\Framework\App\Config\ReinitableConfigInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\App\Config\Storage\WriterInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Store\Api\StoreRepositoryInterface;
use Magento\Store\Model\ResourceModel\Store as StoreResource;
use Magento\Store\Model\ScopeInterface;
use Magento\Store\Model\StoreFactory;
use Magento\Store\Model\StoreManagerInterface;
use Psr\Log\LoggerInterface;
use MageSail\Magesail\Model\NginxGlobalMapManager;
/**
* Creates a dedicated store view with tunnel base URLs; tears it down when the tunnel is stopped.
*/
class TunnelStoreProvisioner
{
public const STORE_CODE = 'magesail_tunnel';
private const PATH_UNSECURE_BASE = 'web/unsecure/base_url';
private const PATH_SECURE_BASE = 'web/secure/base_url';
private const PATH_USE_IN_FRONTEND = 'web/secure/use_in_frontend';
private const PATH_USE_IN_ADMIN = 'web/secure/use_in_adminhtml';
public function __construct(
private readonly StoreManagerInterface $storeManager,
private readonly StoreFactory $storeFactory,
private readonly StoreResource $storeResource,
private readonly StoreRepositoryInterface $storeRepository,
private readonly WriterInterface $configWriter,
private readonly TypeListInterface $typeList,
private readonly ReinitableConfigInterface $reinitableConfig,
private readonly TunnelStoreState $tunnelStoreState,
private readonly LoggerInterface $logger,
private readonly NginxGlobalMapManager $nginxMapManager,
private readonly ScopeConfigInterface $scopeConfig
) {
}
/**
* Remove store + state file after stale PID cleanup when auto-restart is disabled.
*/
public function teardownAfterStaleProcess(): void
{
$this->teardownPersistedState();
}
/**
* Remove tunnel store and state file (idempotent).
*/
public function teardownPersistedState(): void
{
$state = $this->tunnelStoreState->read();
$this->tunnelStoreState->delete();
if ($state === null) {
$this->flushConfigCache();
return;
}
$store = $this->storeFactory->create();
$this->storeResource->load($store, $state['store_id']);
if (!$store->getId()) {
$this->flushConfigCache();
return;
}
$websiteId = (int) $store->getWebsiteId();
try {
$website = $this->storeManager->getWebsite($websiteId);
} catch (LocalizedException $e) {
$this->logger->warning('MageSail tunnel store teardown: ' . $e->getMessage());
$this->flushConfigCache();
return;
}
$defaultStoreId = (int) $website->getDefaultStoreId();
if ((int) $store->getId() === $defaultStoreId) {
$this->logger->warning(
'MageSail: tunnel store is the website default; skipping delete. Change default store view in Admin.'
);
$this->flushConfigCache();
return;
}
try {
$hostname = $this->nginxMapManager->extractHostname($state['unsecure_base_url']);
$this->nginxMapManager->removeTunnelMapping($hostname);
$validation = $this->nginxMapManager->validateAndReload();
if (!$validation['valid']) {
$this->logger->warning('NGINX config validation failed after tunnel map removal: ' . ($validation['error'] ?? 'unknown'));
} elseif (!$validation['reloaded']) {
$this->logger->warning('NGINX config valid but reload failed after map removal: ' . ($validation['error'] ?? 'unknown'));
}
} catch (LocalizedException $e) {
$this->logger->warning('NGINX global map removal skipped: ' . $e->getMessage());
}
try {
$this->storeResource->delete($store);
$this->storeRepository->clean();
$this->storeManager->reinitStores();
} catch (\Throwable $e) {
$this->logger->error('MageSail tunnel store delete failed: ' . $e->getMessage());
}
$this->flushConfigCache();
}
/**
* @return array{store_id: int, store_code: string, nginx_reloaded: bool}
* @throws LocalizedException
*/
public function provision(string $unsecureBase, string $secureBase, bool $useSecureUrls): array
{
$unsecureBase = $this->normalizeBaseUrl($unsecureBase);
$secureBase = $this->normalizeBaseUrl($secureBase !== '' ? $secureBase : $this->deriveSecureFromUnsecure($unsecureBase));
$this->assertValidUrl($unsecureBase);
$this->assertValidUrl($secureBase);
$default = $this->storeManager->getDefaultStoreView();
if ($default === null) {
throw new LocalizedException(__('No default store view is configured.'));
}
$websiteId = (int) $default->getWebsiteId();
$groupId = (int) $default->getStoreGroupId();
$store = $this->loadOrCreateStore($websiteId, $groupId);
$storeId = (int) $store->getId();
$this->configWriter->save(self::PATH_UNSECURE_BASE, $unsecureBase, ScopeInterface::SCOPE_STORES, $storeId);
$this->configWriter->save(self::PATH_SECURE_BASE, $secureBase, ScopeInterface::SCOPE_STORES, $storeId);
$useSecure = $useSecureUrls ? '1' : '0';
$this->configWriter->save(self::PATH_USE_IN_FRONTEND, $useSecure, ScopeInterface::SCOPE_STORES, $storeId);
$this->configWriter->save(self::PATH_USE_IN_ADMIN, $useSecure, ScopeInterface::SCOPE_STORES, $storeId);
$disableRedirect = (bool) $this->scopeConfig->getValue('magesail/nginx/disable_base_url_redirect');
if ($disableRedirect) {
$this->configWriter->save('web/url/redirect_to_base', '0', ScopeInterface::SCOPE_STORES, $storeId);
}
$nginxReloaded = true;
try {
$hostname = $this->nginxMapManager->extractHostname($unsecureBase);
$this->nginxMapManager->addTunnelMapping($hostname, self::STORE_CODE);
$validation = $this->nginxMapManager->validateAndReload();
if (!$validation['valid']) {
$msg = 'NGINX config validation failed after tunnel map update: ' . ($validation['error'] ?? 'unknown');
$this->logger->error($msg);
throw new LocalizedException(__('Tunnel store was not finalized: NGINX map validation failed: %1', $validation['error'] ?? 'unknown'));
}
$nginxReloaded = !empty($validation['reloaded']);
if (!$nginxReloaded) {
$this->logger->warning(
'NGINX map updated for tunnel but reload failed (reload nginx manually): '
. ($validation['error'] ?? 'unknown')
);
}
} catch (LocalizedException $e) {
$this->logger->error('NGINX global map update failed: ' . $e->getMessage());
throw $e;
}
$this->tunnelStoreState->write([
'store_id' => $storeId,
'store_code' => self::STORE_CODE,
'unsecure_base_url' => $unsecureBase,
'secure_base_url' => $secureBase,
'use_secure_urls' => $useSecureUrls,
'nginx_pending_reload' => !$nginxReloaded,
]);
$this->storeRepository->clean();
$this->storeManager->reinitStores();
$this->flushConfigCache();
return [
'store_id' => $storeId,
'store_code' => self::STORE_CODE,
'nginx_reloaded' => $nginxReloaded,
];
}
/**
* @return array{store_id: int, store_code: string, unsecure_base_url: string, secure_base_url: string, use_secure_urls: bool, nginx_pending_reload?: bool}|null
*/
public function getPersistedState(): ?array
{
return $this->tunnelStoreState->read();
}
/**
* Call after nginx reload succeeds (e.g. Admin NGINX map "reload") to clear the tunnel "reload nginx" banner.
*/
public function clearNginxPendingReloadFlag(): void
{
$state = $this->tunnelStoreState->read();
if ($state === null || !($state['nginx_pending_reload'] ?? false)) {
return;
}
$state['nginx_pending_reload'] = false;
$this->tunnelStoreState->write($state);
}
private function loadOrCreateStore(int $websiteId, int $groupId): \Magento\Store\Api\Data\StoreInterface
{
try {
$existing = $this->storeRepository->get(self::STORE_CODE);
$store = $existing;
$store->setName((string) __('MageSail Tunnel'));
$store->setWebsiteId($websiteId);
$store->setStoreGroupId($groupId);
$store->setIsActive(true);
$this->storeResource->save($store);
return $store;
} catch (NoSuchEntityException) {
$store = $this->storeFactory->create();
$store->setCode(self::STORE_CODE);
$store->setName((string) __('MageSail Tunnel'));
$store->setWebsiteId($websiteId);
$store->setStoreGroupId($groupId);
$store->setIsActive(true);
$this->storeResource->save($store);
return $store;
}
}
private function normalizeBaseUrl(string $url): string
{
return rtrim(trim($url), '/') . '/';
}
private function deriveSecureFromUnsecure(string $unsecureBase): string
{
if (stripos($unsecureBase, 'http://') === 0) {
return 'https://' . substr($unsecureBase, \strlen('http://'));
}
return $unsecureBase;
}
private function assertValidUrl(string $url): void
{
if (filter_var($url, FILTER_VALIDATE_URL) === false) {
throw new LocalizedException(__('Invalid base URL: %1', $url));
}
$scheme = parse_url($url, PHP_URL_SCHEME);
if (!\in_array($scheme, ['http', 'https'], true)) {
throw new LocalizedException(__('Base URL must use http or https: %1', $url));
}
}
private function flushConfigCache(): void
{
$this->typeList->cleanType('config');
$this->typeList->cleanType('full_page');
$this->reinitableConfig->reinit();
}
}