Store views, Globalmaps - Confirmed Loading
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MageSail\Magesail\Block\Adminhtml\Nginx;
|
||||
|
||||
use Magento\Backend\Block\Template;
|
||||
use Magento\Backend\Block\Template\Context;
|
||||
use MageSail\Magesail\Model\NginxGlobalMapManager;
|
||||
|
||||
class Map extends Template
|
||||
{
|
||||
public function __construct(
|
||||
Context $context,
|
||||
private readonly NginxGlobalMapManager $nginxMapManager,
|
||||
array $data = []
|
||||
) {
|
||||
parent::__construct($context, $data);
|
||||
}
|
||||
|
||||
public function getMapFilePath(): ?string
|
||||
{
|
||||
return $this->nginxMapManager->getMapFilePath();
|
||||
}
|
||||
|
||||
public function getMapContent(): string
|
||||
{
|
||||
try {
|
||||
return $this->nginxMapManager->readMapFile();
|
||||
} catch (\Exception) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
public function getMapUrl(): string
|
||||
{
|
||||
return $this->getUrl('*/*/map');
|
||||
}
|
||||
|
||||
public function getFormKey(): string
|
||||
{
|
||||
return $this->formKey->getFormKey();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace MageSail\Magesail\Block\Adminhtml;
|
||||
use Magento\Backend\Block\Template;
|
||||
use Magento\Backend\Block\Template\Context;
|
||||
use MageSail\Magesail\Model\TunnelStatus;
|
||||
use MageSail\Magesail\Model\TunnelStoreProvisioner;
|
||||
|
||||
class Tunnel extends Template
|
||||
{
|
||||
@@ -15,6 +16,7 @@ class Tunnel extends Template
|
||||
public function __construct(
|
||||
Context $context,
|
||||
private readonly TunnelStatus $tunnelStatus,
|
||||
private readonly TunnelStoreProvisioner $tunnelStoreProvisioner,
|
||||
array $data = []
|
||||
) {
|
||||
parent::__construct($context, $data);
|
||||
@@ -28,6 +30,21 @@ class Tunnel extends Template
|
||||
return $this->tunnelStatus->getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted tunnel store (present while tunnel is configured; may exist briefly before PID appears).
|
||||
*
|
||||
* @return array{store_id: int, store_code: string, unsecure_base_url: string, secure_base_url: string, use_secure_urls: bool}|null
|
||||
*/
|
||||
public function getTunnelStoreState(): ?array
|
||||
{
|
||||
return $this->tunnelStoreProvisioner->getPersistedState();
|
||||
}
|
||||
|
||||
public function getTunnelStoreCodeConstant(): string
|
||||
{
|
||||
return TunnelStoreProvisioner::STORE_CODE;
|
||||
}
|
||||
|
||||
public function getConfigUrl(): string
|
||||
{
|
||||
return $this->getUrl('adminhtml/system_config/edit/section/magesail');
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MageSail\Magesail\Controller\Adminhtml\Nginx;
|
||||
|
||||
use Magento\Backend\App\Action;
|
||||
use Magento\Backend\App\Action\Context;
|
||||
use Magento\Framework\Controller\ResultFactory;
|
||||
use Magento\Framework\Exception\LocalizedException;
|
||||
use MageSail\Magesail\Model\NginxGlobalMapManager;
|
||||
use MageSail\Magesail\Model\NginxMapParser;
|
||||
use MageSail\Magesail\Model\TunnelStoreProvisioner;
|
||||
|
||||
class Map extends Action
|
||||
{
|
||||
public function __construct(
|
||||
Context $context,
|
||||
private readonly NginxGlobalMapManager $nginxMapManager,
|
||||
private readonly NginxMapParser $mapParser,
|
||||
private readonly TunnelStoreProvisioner $tunnelStoreProvisioner
|
||||
) {
|
||||
parent::__construct($context);
|
||||
}
|
||||
|
||||
public function execute()
|
||||
{
|
||||
$action = $this->getRequest()->getParam('action');
|
||||
$wantJson = $this->isAjaxRequest();
|
||||
|
||||
if ($action === 'read') {
|
||||
return $this->handleRead($wantJson);
|
||||
}
|
||||
if ($action === 'write') {
|
||||
return $this->handleWrite($wantJson);
|
||||
}
|
||||
if ($action === 'validate') {
|
||||
return $this->handleValidate($wantJson);
|
||||
}
|
||||
if ($action === 'reload') {
|
||||
return $this->handleReload($wantJson);
|
||||
}
|
||||
if ($action === 'parse') {
|
||||
return $this->handleParse($wantJson);
|
||||
}
|
||||
if ($action === 'save_blocks') {
|
||||
return $this->handleSaveBlocks($wantJson);
|
||||
}
|
||||
if ($action === 'diagnostics') {
|
||||
return $this->handleDiagnostics($wantJson);
|
||||
}
|
||||
|
||||
$resultPage = $this->resultFactory->create(ResultFactory::TYPE_PAGE);
|
||||
$resultPage->setActiveMenu('MageSail_Magesail::config');
|
||||
$resultPage->getConfig()->getTitle()->prepend(__('NGINX Global Map'));
|
||||
return $resultPage;
|
||||
}
|
||||
|
||||
private function isAjaxRequest(): bool
|
||||
{
|
||||
return strtolower((string) $this->getRequest()->getHeader('X-Requested-With')) === 'xmlhttprequest'
|
||||
|| (string) $this->getRequest()->getParam('ajax') === '1';
|
||||
}
|
||||
|
||||
private function jsonResponse(bool $success, string $message = '', array $data = [])
|
||||
{
|
||||
$json = $this->resultFactory->create(ResultFactory::TYPE_JSON);
|
||||
$json->setData(array_merge([
|
||||
'success' => $success,
|
||||
'message' => $message,
|
||||
], $data));
|
||||
return $json;
|
||||
}
|
||||
|
||||
private function handleRead(bool $wantJson)
|
||||
{
|
||||
try {
|
||||
$content = $this->nginxMapManager->readMapFile();
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(true, '', ['content' => $content]);
|
||||
}
|
||||
$this->messageManager->addSuccessMessage(__('Map file loaded.'));
|
||||
return $this->_redirect('*/*/map');
|
||||
} catch (LocalizedException $e) {
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(false, (string) $e->getMessage());
|
||||
}
|
||||
$this->messageManager->addErrorMessage($e->getMessage());
|
||||
return $this->_redirect('*/*/map');
|
||||
}
|
||||
}
|
||||
|
||||
private function handleWrite(bool $wantJson)
|
||||
{
|
||||
$content = (string) $this->getRequest()->getParam('content', '');
|
||||
try {
|
||||
$this->nginxMapManager->writeMapFile($content);
|
||||
$validation = $this->nginxMapManager->validateNginxConfig();
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(
|
||||
true,
|
||||
(string) ($validation['valid'] ? __('Map file saved and validated.') : __('Map file saved, but NGINX validation failed.')),
|
||||
['validation' => $validation]
|
||||
);
|
||||
}
|
||||
if ($validation['valid']) {
|
||||
$this->messageManager->addSuccessMessage(__('Map file saved and validated.'));
|
||||
} else {
|
||||
$this->messageManager->addWarningMessage(__('Map file saved, but NGINX validation failed: %1', $validation['error'] ?? ''));
|
||||
}
|
||||
return $this->_redirect('*/*/map');
|
||||
} catch (LocalizedException $e) {
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(false, (string) $e->getMessage());
|
||||
}
|
||||
$this->messageManager->addErrorMessage($e->getMessage());
|
||||
return $this->_redirect('*/*/map');
|
||||
} catch (\Throwable $e) {
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(false, (string) __('Could not save map file: %1', $e->getMessage()));
|
||||
}
|
||||
$this->messageManager->addErrorMessage((string) __('Could not save map file: %1', $e->getMessage()));
|
||||
return $this->_redirect('*/*/map');
|
||||
}
|
||||
}
|
||||
|
||||
private function handleValidate(bool $wantJson)
|
||||
{
|
||||
$validation = $this->nginxMapManager->validateNginxConfig();
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse($validation['valid'], '', ['validation' => $validation]);
|
||||
}
|
||||
if ($validation['valid']) {
|
||||
$this->messageManager->addSuccessMessage(__('NGINX configuration is valid.'));
|
||||
} else {
|
||||
$this->messageManager->addErrorMessage(__('NGINX configuration validation failed: %1', $validation['error'] ?? ''));
|
||||
}
|
||||
return $this->_redirect('*/*/map');
|
||||
}
|
||||
|
||||
private function handleReload(bool $wantJson)
|
||||
{
|
||||
$validation = $this->nginxMapManager->validateAndReload();
|
||||
if ($validation['valid'] && $validation['reloaded']) {
|
||||
$this->tunnelStoreProvisioner->clearNginxPendingReloadFlag();
|
||||
}
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(
|
||||
$validation['valid'] && $validation['reloaded'],
|
||||
(string) ($validation['valid'] && $validation['reloaded']
|
||||
? __('NGINX validated and reloaded successfully.')
|
||||
: ($validation['error'] ?? __('NGINX validation or reload failed.'))),
|
||||
['validation' => $validation]
|
||||
);
|
||||
}
|
||||
if ($validation['valid'] && $validation['reloaded']) {
|
||||
$this->messageManager->addSuccessMessage(__('NGINX validated and reloaded successfully.'));
|
||||
} else {
|
||||
$this->messageManager->addErrorMessage(__('NGINX validation or reload failed: %1', $validation['error'] ?? ''));
|
||||
}
|
||||
return $this->_redirect('*/*/map');
|
||||
}
|
||||
|
||||
private function handleParse(bool $wantJson)
|
||||
{
|
||||
try {
|
||||
$content = $this->nginxMapManager->readMapFile();
|
||||
$parsed = $this->mapParser->parse($content);
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(true, '', ['parsed' => $parsed, 'raw' => $content]);
|
||||
}
|
||||
return $this->_redirect('*/*/map');
|
||||
} catch (LocalizedException $e) {
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(false, (string) $e->getMessage());
|
||||
}
|
||||
$this->messageManager->addErrorMessage($e->getMessage());
|
||||
return $this->_redirect('*/*/map');
|
||||
}
|
||||
}
|
||||
|
||||
private function handleSaveBlocks(bool $wantJson)
|
||||
{
|
||||
try {
|
||||
$original = $this->nginxMapManager->readMapFile();
|
||||
$blocksJson = (string) $this->getRequest()->getParam('blocks', '');
|
||||
$guiBlocks = json_decode($blocksJson, true);
|
||||
if (!\is_array($guiBlocks)) {
|
||||
throw new LocalizedException(__('Invalid blocks data (JSON).'));
|
||||
}
|
||||
$fileParsed = $this->mapParser->parse($original);
|
||||
$fileBlocks = $fileParsed['blocks'];
|
||||
if (\count($guiBlocks) !== \count($fileBlocks)) {
|
||||
throw new LocalizedException(__(
|
||||
'Block count mismatch (%1 in editor, %2 in file). Click "Parse & Load Blocks" and try again.',
|
||||
\count($guiBlocks),
|
||||
\count($fileBlocks)
|
||||
));
|
||||
}
|
||||
$mergedBlocks = [];
|
||||
foreach ($fileBlocks as $idx => $fileBlock) {
|
||||
$g = $guiBlocks[$idx] ?? null;
|
||||
if (!\is_array($g)) {
|
||||
throw new LocalizedException(__('Invalid block at index %1.', $idx));
|
||||
}
|
||||
$src = (string) ($g['source'] ?? '');
|
||||
$tgt = (string) ($g['target'] ?? '');
|
||||
if ($src !== $fileBlock['source'] || $tgt !== $fileBlock['target']) {
|
||||
throw new LocalizedException(__(
|
||||
'Map $%1 $%2 no longer matches the file. Click "Parse & Load Blocks" again.',
|
||||
$src,
|
||||
$tgt
|
||||
));
|
||||
}
|
||||
$rawEntries = $g['entries'] ?? [];
|
||||
if (!\is_array($rawEntries)) {
|
||||
throw new LocalizedException(__('Invalid entries for map $%1 $%2.', $src, $tgt));
|
||||
}
|
||||
$entries = [];
|
||||
foreach ($rawEntries as $e) {
|
||||
if (!\is_array($e)) {
|
||||
continue;
|
||||
}
|
||||
$entries[] = [
|
||||
'hostname' => (string) ($e['hostname'] ?? ''),
|
||||
'value' => (string) ($e['value'] ?? ''),
|
||||
'comment' => isset($e['comment']) && $e['comment'] !== '' && $e['comment'] !== null
|
||||
? (string) $e['comment']
|
||||
: null,
|
||||
];
|
||||
}
|
||||
$mergedBlocks[] = array_merge($fileBlock, ['entries' => $entries]);
|
||||
}
|
||||
$newContent = $this->mapParser->rebuild($original, ['blocks' => $mergedBlocks]);
|
||||
$this->nginxMapManager->writeMapFile($newContent);
|
||||
$validation = $this->nginxMapManager->validateNginxConfig();
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(
|
||||
true,
|
||||
(string) ($validation['valid'] ? __('Map blocks saved and validated.') : __('Map blocks saved, but NGINX validation failed.')),
|
||||
['validation' => $validation]
|
||||
);
|
||||
}
|
||||
if ($validation['valid']) {
|
||||
$this->messageManager->addSuccessMessage(__('Map blocks saved and validated.'));
|
||||
} else {
|
||||
$this->messageManager->addWarningMessage(__('Map blocks saved, but NGINX validation failed: %1', $validation['error'] ?? ''));
|
||||
}
|
||||
return $this->_redirect('*/*/map');
|
||||
} catch (LocalizedException $e) {
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(false, (string) $e->getMessage());
|
||||
}
|
||||
$this->messageManager->addErrorMessage($e->getMessage());
|
||||
return $this->_redirect('*/*/map');
|
||||
} catch (\Throwable $e) {
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(false, (string) __('Could not save map blocks: %1', $e->getMessage()));
|
||||
}
|
||||
$this->messageManager->addErrorMessage((string) __('Could not save map blocks: %1', $e->getMessage()));
|
||||
return $this->_redirect('*/*/map');
|
||||
}
|
||||
}
|
||||
|
||||
private function handleDiagnostics(bool $wantJson)
|
||||
{
|
||||
$diag = $this->nginxMapManager->getDiagnostics();
|
||||
if ($wantJson) {
|
||||
return $this->jsonResponse(true, '', ['diagnostics' => $diag]);
|
||||
}
|
||||
return $this->_redirect('*/*/map');
|
||||
}
|
||||
|
||||
protected function _isAllowed()
|
||||
{
|
||||
return $this->_authorization->isAllowed('MageSail_Magesail::config');
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ use Magento\Backend\App\Action;
|
||||
use Magento\Backend\App\Action\Context;
|
||||
use Magento\Framework\App\Config\ScopeConfigInterface;
|
||||
use Magento\Framework\Controller\ResultFactory;
|
||||
use Magento\Framework\Exception\LocalizedException;
|
||||
use MageSail\Magesail\Model\MagesailLog;
|
||||
use MageSail\Magesail\Model\TunnelManager;
|
||||
use MageSail\Magesail\Model\TunnelStatus;
|
||||
use MageSail\Magesail\Model\TunnelStoreProvisioner;
|
||||
|
||||
class Index extends Action
|
||||
{
|
||||
@@ -18,7 +20,8 @@ class Index extends Action
|
||||
private readonly ScopeConfigInterface $scopeConfig,
|
||||
private readonly TunnelManager $tunnelManager,
|
||||
private readonly TunnelStatus $tunnelStatus,
|
||||
private readonly MagesailLog $magesailLog
|
||||
private readonly MagesailLog $magesailLog,
|
||||
private readonly TunnelStoreProvisioner $tunnelStoreProvisioner
|
||||
) {
|
||||
parent::__construct($context);
|
||||
}
|
||||
@@ -76,17 +79,57 @@ class Index extends Action
|
||||
$this->messageManager->addErrorMessage(__('Invalid port. Must be 80 or 443.'));
|
||||
return $this->_redirect('*/*/index');
|
||||
}
|
||||
$tunnelBaseUrl = trim((string) $this->getRequest()->getParam('tunnel_base_url', ''));
|
||||
if ($tunnelBaseUrl === '') {
|
||||
$msg = (string) __('Tunnel base URL is required (must match your holesail-browser virtual host).');
|
||||
if ($wantJson) {
|
||||
return $this->jsonPayload(false, $msg);
|
||||
}
|
||||
$this->messageManager->addErrorMessage($msg);
|
||||
return $this->_redirect('*/*/index');
|
||||
}
|
||||
$tunnelSecureBaseUrl = trim((string) $this->getRequest()->getParam('tunnel_secure_base_url', ''));
|
||||
$useSecureUrls = $this->isTruthyParam($this->getRequest()->getParam('tunnel_use_secure_urls'));
|
||||
try {
|
||||
$provision = $this->tunnelStoreProvisioner->provision($tunnelBaseUrl, $tunnelSecureBaseUrl, $useSecureUrls);
|
||||
} catch (LocalizedException $e) {
|
||||
$this->magesailLog->add('Tunnel store provision failed: ' . $e->getMessage());
|
||||
if ($wantJson) {
|
||||
return $this->jsonPayload(false, (string) $e->getMessage());
|
||||
}
|
||||
$this->messageManager->addErrorMessage($e->getMessage());
|
||||
return $this->_redirect('*/*/index');
|
||||
}
|
||||
$result = $this->tunnelManager->start((int) $port, $secure);
|
||||
if (!$result['ok']) {
|
||||
$this->tunnelStoreProvisioner->teardownPersistedState();
|
||||
}
|
||||
if ($result['ok']) {
|
||||
$this->magesailLog->add(sprintf('Tunnel started PID %s port %s', $result['pid'], $port));
|
||||
$msg = (string) __(
|
||||
'Tunnel started (PID: %1). Key will appear once ready.',
|
||||
$result['pid']
|
||||
);
|
||||
$nginxReloadMsg = '';
|
||||
if (empty($provision['nginx_reloaded'])) {
|
||||
$nginxReloadMsg = (string) __(
|
||||
'The NGINX global map was updated, but Magento could not reload nginx (common when the PHP user cannot signal the nginx master). ' .
|
||||
'On the server run: sudo systemctl reload nginx, or set Reload wrapper script in Admin under Configuration for the Holesail Tunnel NGINX settings. ' .
|
||||
'Until nginx reloads, your tunnel hostname may not route to the tunnel store.'
|
||||
);
|
||||
}
|
||||
if ($wantJson) {
|
||||
return $this->jsonPayload(true, $msg);
|
||||
$extra = [];
|
||||
if ($nginxReloadMsg !== '') {
|
||||
$extra['nginx_reload_required'] = true;
|
||||
$extra['nginx_reload_message'] = $nginxReloadMsg;
|
||||
}
|
||||
return $this->jsonPayload(true, $msg, $extra);
|
||||
}
|
||||
$this->messageManager->addSuccessMessage($msg);
|
||||
if ($nginxReloadMsg !== '') {
|
||||
$this->messageManager->addWarningMessage($nginxReloadMsg);
|
||||
}
|
||||
return $this->_redirect('*/*/index');
|
||||
} else {
|
||||
$msg = match ($result['error'] ?? '') {
|
||||
@@ -107,6 +150,10 @@ class Index extends Action
|
||||
private function handleStop(bool $wantJson)
|
||||
{
|
||||
$stop = $this->tunnelManager->stop();
|
||||
$stoppedOrIdle = $stop['ok'] || (($stop['error'] ?? '') === 'not_running');
|
||||
if ($stoppedOrIdle) {
|
||||
$this->tunnelStoreProvisioner->teardownPersistedState();
|
||||
}
|
||||
if ($stop['ok']) {
|
||||
$this->magesailLog->add('Tunnel stopped');
|
||||
if ($wantJson) {
|
||||
@@ -129,6 +176,18 @@ class Index extends Action
|
||||
return $wantJson ? $this->jsonPayload($stop['ok'], '') : $this->_redirect('*/*/index');
|
||||
}
|
||||
|
||||
private function isTruthyParam(mixed $value): bool
|
||||
{
|
||||
if ($value === true || $value === 1) {
|
||||
return true;
|
||||
}
|
||||
if ($value === false || $value === null || $value === '') {
|
||||
return false;
|
||||
}
|
||||
$s = strtolower(trim((string) $value));
|
||||
return \in_array($s, ['1', 'true', 'on', 'yes'], true);
|
||||
}
|
||||
|
||||
protected function _isAllowed()
|
||||
{
|
||||
return $this->_authorization->isAllowed('MageSail_Magesail::tunnel');
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MageSail\Magesail\Model;
|
||||
|
||||
use Magento\Framework\App\Config\ScopeConfigInterface;
|
||||
use Magento\Framework\Exception\LocalizedException;
|
||||
use Magento\Framework\Shell;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Updates Ansible-style global map files: map $http_host $MAGE_RUN_CODE { ... } and $MAGE_RUN_TYPE { ... }.
|
||||
*/
|
||||
class NginxGlobalMapManager
|
||||
{
|
||||
private const XML_MAP_PATH = 'magesail/nginx/global_map_path';
|
||||
private const XML_NGINX_BINARY = 'magesail/nginx/nginx_binary';
|
||||
private const XML_MAP_SOURCE = 'magesail/nginx/map_source_variable';
|
||||
private const XML_MAP_RUN_CODE = 'magesail/nginx/map_run_code_variable';
|
||||
private const XML_MAP_RUN_TYPE = 'magesail/nginx/map_run_type_variable';
|
||||
private const XML_MAP_RUN_TYPE_VALUE = 'magesail/nginx/map_run_type_value';
|
||||
private const XML_TEST_PID_PATH = 'magesail/nginx/test_pid_path';
|
||||
private const XML_RELOAD_WRAPPER = 'magesail/nginx/reload_wrapper_script';
|
||||
|
||||
public const LINE_COMMENT = '# MageSail tunnel';
|
||||
|
||||
public function __construct(
|
||||
private readonly ScopeConfigInterface $scopeConfig,
|
||||
private readonly Shell $shell,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
}
|
||||
|
||||
public function getMapFilePath(): ?string
|
||||
{
|
||||
$path = trim((string) $this->scopeConfig->getValue(self::XML_MAP_PATH));
|
||||
return $path !== '' ? $path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws LocalizedException
|
||||
*/
|
||||
public function readMapFile(): string
|
||||
{
|
||||
$path = $this->getMapFilePath();
|
||||
if ($path === null) {
|
||||
throw new LocalizedException(__('NGINX global map path is not configured.'));
|
||||
}
|
||||
if (!\is_readable($path)) {
|
||||
throw new LocalizedException(__('NGINX global map file is not readable: %1', $path));
|
||||
}
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
throw new LocalizedException(__('Failed to read NGINX global map file: %1', $path));
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws LocalizedException
|
||||
*/
|
||||
public function writeMapFile(string $content): void
|
||||
{
|
||||
$path = $this->getMapFilePath();
|
||||
if ($path === null) {
|
||||
throw new LocalizedException(__('NGINX global map path is not configured.'));
|
||||
}
|
||||
$dir = \dirname($path);
|
||||
if (!\is_dir($dir)) {
|
||||
throw new LocalizedException(__('NGINX global map directory does not exist: %1', $dir));
|
||||
}
|
||||
$fileExists = \is_file($path);
|
||||
if (!$fileExists) {
|
||||
if (!\is_writable($dir)) {
|
||||
throw new LocalizedException(
|
||||
__(
|
||||
'NGINX global map directory is not writable: %1. Create the file first (e.g. touch) and chown/chgrp ' .
|
||||
'so the PHP-FPM user can write it, or grant ACL/group write on the directory.',
|
||||
$dir
|
||||
)
|
||||
);
|
||||
}
|
||||
} elseif (!\is_writable($path)) {
|
||||
throw new LocalizedException(
|
||||
__(
|
||||
'NGINX global map file is not writable: %1. Ensure the PHP-FPM user can write this file ' .
|
||||
'(e.g. group jetrails with mode 664 and usermod -aG jetrails <php_user>, or setfacl -m u:<php_user>:rw).',
|
||||
$path
|
||||
)
|
||||
);
|
||||
}
|
||||
if (file_put_contents($path, $content, LOCK_EX) === false) {
|
||||
throw new LocalizedException(__('Failed to write NGINX global map file: %1', $path));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add host → store code in $MAGE_RUN_CODE map and host → store in $MAGE_RUN_TYPE map.
|
||||
*
|
||||
* @throws LocalizedException
|
||||
*/
|
||||
public function addTunnelMapping(string $hostname, string $storeCode): void
|
||||
{
|
||||
$content = $this->readMapFile();
|
||||
$content = $this->removeTunnelMappingFromContent($content, $hostname);
|
||||
$runTypeValue = trim((string) $this->scopeConfig->getValue(self::XML_MAP_RUN_TYPE_VALUE)) ?: 'store';
|
||||
$lineCode = $this->buildMapLine($hostname, $storeCode);
|
||||
$lineType = $this->buildMapLine($hostname, $runTypeValue);
|
||||
$content = $this->insertIntoNamedMap($content, $this->getMapRunCodeVar(), $lineCode);
|
||||
$content = $this->insertIntoNamedMap($content, $this->getMapRunTypeVar(), $lineType);
|
||||
$this->writeMapFile($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws LocalizedException
|
||||
*/
|
||||
public function removeTunnelMapping(string $hostname): void
|
||||
{
|
||||
$content = $this->readMapFile();
|
||||
$updated = $this->removeTunnelMappingFromContent($content, $hostname);
|
||||
if ($updated !== $content) {
|
||||
$this->writeMapFile($updated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{valid: bool, output?: string, error?: string}
|
||||
*/
|
||||
public function validateNginxConfig(): array
|
||||
{
|
||||
$mapPath = $this->getMapFilePath();
|
||||
if ($mapPath === null) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'Global map path is not configured.',
|
||||
'output' => '',
|
||||
];
|
||||
}
|
||||
if (!\is_readable($mapPath)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'Global map file is not readable: ' . $mapPath,
|
||||
'output' => '',
|
||||
];
|
||||
}
|
||||
$configuredPid = trim((string) $this->scopeConfig->getValue(self::XML_TEST_PID_PATH));
|
||||
$cleanupPidFile = $configuredPid === '';
|
||||
$pidPath = $cleanupPidFile
|
||||
? rtrim(sys_get_temp_dir(), \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR
|
||||
. 'magesail-nginx-test-' . getmypid() . '-' . bin2hex(random_bytes(4)) . '.pid'
|
||||
: $configuredPid;
|
||||
$tempConf = rtrim(sys_get_temp_dir(), \DIRECTORY_SEPARATOR)
|
||||
. \DIRECTORY_SEPARATOR . 'magesail-nginx-minimal-test-' . getmypid() . '.conf';
|
||||
$body = $this->buildMinimalNginxTestConfig($pidPath, $mapPath);
|
||||
if (file_put_contents($tempConf, $body, LOCK_EX) === false) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => 'Could not write temporary nginx test config: ' . $tempConf,
|
||||
'output' => '',
|
||||
];
|
||||
}
|
||||
try {
|
||||
$result = $this->runNginxProcess(['-t', '-c', $tempConf]);
|
||||
} finally {
|
||||
if (\is_file($tempConf)) {
|
||||
@unlink($tempConf);
|
||||
}
|
||||
if ($cleanupPidFile && \is_file($pidPath)) {
|
||||
@unlink($pidPath);
|
||||
}
|
||||
}
|
||||
if ($result['exitCode'] === 0) {
|
||||
$note = $result['output'] !== ''
|
||||
? $result['output'] . "\n"
|
||||
: '';
|
||||
$note .= '(syntax check: minimal config + include ' . $mapPath . ')';
|
||||
return ['valid' => true, 'output' => trim($note)];
|
||||
}
|
||||
$this->logger->error('NGINX config validation failed: ' . $result['output']);
|
||||
$err = $result['output'] !== ''
|
||||
? $result['output']
|
||||
: 'nginx -t exited with code ' . $result['exitCode'];
|
||||
return [
|
||||
'valid' => false,
|
||||
'error' => $err,
|
||||
'output' => $result['output'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, output?: string, error?: string}
|
||||
*/
|
||||
public function reloadNginx(): array
|
||||
{
|
||||
$wrapper = trim((string) $this->scopeConfig->getValue(self::XML_RELOAD_WRAPPER));
|
||||
if ($wrapper !== '') {
|
||||
if (!\is_executable($wrapper)) {
|
||||
$msg = 'Reload wrapper script is missing or not executable: ' . $wrapper;
|
||||
$this->logger->error('MageSail NGINX: ' . $msg);
|
||||
return ['success' => false, 'error' => $msg, 'output' => ''];
|
||||
}
|
||||
$result = $this->runExternalExecutable([$wrapper]);
|
||||
} else {
|
||||
$result = $this->runNginxProcess(['-s', 'reload']);
|
||||
}
|
||||
if ($result['exitCode'] === 0) {
|
||||
$this->logger->info('NGINX reloaded successfully after tunnel map update.');
|
||||
return ['success' => true, 'output' => $result['output']];
|
||||
}
|
||||
$this->logger->error('NGINX reload failed: ' . $result['output']);
|
||||
$err = $result['output'] !== ''
|
||||
? $result['output']
|
||||
: 'nginx reload exited with code ' . $result['exitCode'];
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $err,
|
||||
'output' => $result['output'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{valid: bool, reloaded: bool, error?: string, output?: string}
|
||||
*/
|
||||
public function validateAndReload(): array
|
||||
{
|
||||
$validation = $this->validateNginxConfig();
|
||||
if (!$validation['valid']) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'reloaded' => false,
|
||||
'error' => $validation['error'] ?? 'NGINX config validation failed',
|
||||
'output' => $validation['output'] ?? null,
|
||||
];
|
||||
}
|
||||
$reload = $this->reloadNginx();
|
||||
return [
|
||||
'valid' => true,
|
||||
'reloaded' => $reload['success'],
|
||||
'error' => $reload['success'] ? null : ($reload['error'] ?? 'NGINX reload failed'),
|
||||
'output' => $reload['output'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
public function extractHostname(string $baseUrl): string
|
||||
{
|
||||
$parsed = parse_url($baseUrl);
|
||||
if ($parsed === false || !isset($parsed['host'])) {
|
||||
throw new LocalizedException(__('Could not extract hostname from base URL: %1', $baseUrl));
|
||||
}
|
||||
return $parsed['host'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic info about map file accessibility.
|
||||
*
|
||||
* @return array{path: string|null, exists: bool, readable: bool, writable: bool, error?: string}
|
||||
*/
|
||||
public function getDiagnostics(): array
|
||||
{
|
||||
$path = $this->getMapFilePath();
|
||||
if ($path === null) {
|
||||
return [
|
||||
'path' => null,
|
||||
'exists' => false,
|
||||
'readable' => false,
|
||||
'writable' => false,
|
||||
'error' => 'Map file path is not configured in Stores → Configuration → Holesail Tunnel → NGINX Configuration.',
|
||||
];
|
||||
}
|
||||
$exists = \is_file($path);
|
||||
$readable = $exists && \is_readable($path);
|
||||
$writable = $exists && \is_writable($path);
|
||||
$dirWritable = \is_dir(\dirname($path)) && \is_writable(\dirname($path));
|
||||
$error = null;
|
||||
if (!$exists && !$dirWritable) {
|
||||
$error = 'Directory ' . \dirname($path) . ' is not writable.';
|
||||
} elseif (!$exists) {
|
||||
$error = 'File does not exist yet (will be created).';
|
||||
} elseif (!$readable) {
|
||||
$error = 'File exists but is not readable. Check permissions.';
|
||||
} elseif (!$writable) {
|
||||
$error = 'File exists but is not writable. Check permissions.';
|
||||
}
|
||||
return [
|
||||
'path' => $path,
|
||||
'exists' => $exists,
|
||||
'readable' => $readable,
|
||||
'writable' => $writable,
|
||||
'error' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
private function getNginxBinary(): string
|
||||
{
|
||||
$configured = trim((string) $this->scopeConfig->getValue(self::XML_NGINX_BINARY));
|
||||
return $configured !== '' ? $configured : 'nginx';
|
||||
}
|
||||
|
||||
/**
|
||||
* Single pid + events + http { include map } — avoids loading main nginx.conf (duplicate pid, /run/nginx.pid perms).
|
||||
*/
|
||||
private function buildMinimalNginxTestConfig(string $pidPath, string $mapIncludePath): string
|
||||
{
|
||||
$pidLine = 'pid ' . $this->nginxConfigQuotedPath($pidPath) . ';';
|
||||
$includeLine = 'include ' . $this->nginxConfigQuotedPath(str_replace('\\', '/', $mapIncludePath)) . ';';
|
||||
return <<<NGINX
|
||||
error_log stderr;
|
||||
{$pidLine}
|
||||
events {
|
||||
worker_connections 1;
|
||||
}
|
||||
http {
|
||||
{$includeLine}
|
||||
}
|
||||
NGINX;
|
||||
}
|
||||
|
||||
private function nginxConfigQuotedPath(string $path): string
|
||||
{
|
||||
$path = str_replace('\\', '/', $path);
|
||||
if (preg_match('/^[a-zA-Z0-9._\/-]+$/', $path)) {
|
||||
return $path;
|
||||
}
|
||||
return '"' . addcslashes($path, '"\\') . '"';
|
||||
}
|
||||
|
||||
private function getMapSourceVar(): string
|
||||
{
|
||||
$v = trim((string) $this->scopeConfig->getValue(self::XML_MAP_SOURCE));
|
||||
return $v !== '' ? ltrim($v, '$') : 'http_host';
|
||||
}
|
||||
|
||||
private function getMapRunCodeVar(): string
|
||||
{
|
||||
$v = trim((string) $this->scopeConfig->getValue(self::XML_MAP_RUN_CODE));
|
||||
return $v !== '' ? ltrim($v, '$') : 'MAGE_RUN_CODE';
|
||||
}
|
||||
|
||||
private function getMapRunTypeVar(): string
|
||||
{
|
||||
$v = trim((string) $this->scopeConfig->getValue(self::XML_MAP_RUN_TYPE));
|
||||
return $v !== '' ? ltrim($v, '$') : 'MAGE_RUN_TYPE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run nginx with argv (binary from config prepended); merges stdout+stderr.
|
||||
*
|
||||
* @param string[] $args
|
||||
* @return array{exitCode: int, output: string}
|
||||
*/
|
||||
private function runNginxProcess(array $args): array
|
||||
{
|
||||
$binary = escapeshellarg($this->getNginxBinary());
|
||||
$parts = array_map(static fn (string $a): string => escapeshellarg($a), $args);
|
||||
$cmd = $binary . ' ' . implode(' ', $parts);
|
||||
return $this->executeProcOrShellCommand($cmd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an arbitrary executable path with optional args (no nginx binary prefix).
|
||||
*
|
||||
* @param string[] $argv
|
||||
* @return array{exitCode: int, output: string}
|
||||
*/
|
||||
private function runExternalExecutable(array $argv): array
|
||||
{
|
||||
if ($argv === []) {
|
||||
return ['exitCode' => 1, 'output' => 'No command'];
|
||||
}
|
||||
$cmd = implode(' ', array_map(static fn (string $a): string => escapeshellarg($a), $argv));
|
||||
return $this->executeProcOrShellCommand($cmd);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{exitCode: int, output: string}
|
||||
*/
|
||||
private function executeProcOrShellCommand(string $cmd): array
|
||||
{
|
||||
$disabled = explode(',', str_replace(' ', ',', (string) ini_get('disable_functions')));
|
||||
if (\in_array('proc_open', $disabled, true)) {
|
||||
return $this->runNginxViaShell($cmd);
|
||||
}
|
||||
$descriptorspec = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
$process = @proc_open($cmd, $descriptorspec, $pipes, null, null);
|
||||
if (!\is_resource($process)) {
|
||||
return $this->runNginxViaShell($cmd);
|
||||
}
|
||||
fclose($pipes[0]);
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
if ($exitCode === -1) {
|
||||
$exitCode = 1;
|
||||
}
|
||||
$combined = trim(
|
||||
($stdout !== '' ? rtrim($stdout) : '')
|
||||
. ($stderr !== '' ? (($stdout !== '' ? "\n" : '') . rtrim($stderr)) : '')
|
||||
);
|
||||
return ['exitCode' => $exitCode, 'output' => $combined];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback when proc_open is unavailable (merges stderr like 2>&1).
|
||||
*/
|
||||
private function runNginxViaShell(string $cmd): array
|
||||
{
|
||||
try {
|
||||
$wrapped = '/bin/sh -c ' . escapeshellarg($cmd . ' 2>&1');
|
||||
$output = $this->shell->execute($wrapped);
|
||||
return ['exitCode' => 0, 'output' => trim((string) $output)];
|
||||
} catch (\Throwable $e) {
|
||||
$prev = $e->getPrevious();
|
||||
$msg = $prev instanceof \Exception ? $prev->getMessage() : $e->getMessage();
|
||||
return ['exitCode' => 1, 'output' => $msg];
|
||||
}
|
||||
}
|
||||
|
||||
private function buildMapLine(string $hostname, string $value): string
|
||||
{
|
||||
$indent = $this->detectIndentOrDefault();
|
||||
return sprintf(
|
||||
'%s%s %s; %s',
|
||||
$indent,
|
||||
$this->escapeNginxMapKey($hostname),
|
||||
$this->escapeNginxMapValue($value),
|
||||
self::LINE_COMMENT
|
||||
);
|
||||
}
|
||||
|
||||
private function detectIndentOrDefault(): string
|
||||
{
|
||||
try {
|
||||
$content = $this->readMapFile();
|
||||
} catch (LocalizedException) {
|
||||
return ' ';
|
||||
}
|
||||
$src = preg_quote($this->getMapSourceVar(), '/');
|
||||
$code = preg_quote($this->getMapRunCodeVar(), '/');
|
||||
if (!preg_match('/map\s+\$' . $src . '\s+\$' . $code . '\s*\{/s', $content, $m, PREG_OFFSET_CAPTURE)) {
|
||||
return ' ';
|
||||
}
|
||||
$openBracePos = $m[0][1] + \strlen($m[0][0]) - 1;
|
||||
$closePos = $this->findMatchingClosingBrace($content, $openBracePos);
|
||||
if ($closePos === null) {
|
||||
return ' ';
|
||||
}
|
||||
$inner = substr($content, $openBracePos + 1, $closePos - $openBracePos - 1);
|
||||
if (preg_match('/^(\s*)default/m', $inner, $im)) {
|
||||
return $im[1];
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
|
||||
private function escapeNginxMapKey(string $value): string
|
||||
{
|
||||
if (preg_match('/^[a-zA-Z0-9._-]+$/', $value)) {
|
||||
return $value;
|
||||
}
|
||||
return '"' . addcslashes($value, '"\\') . '"';
|
||||
}
|
||||
|
||||
private function escapeNginxMapValue(string $value): string
|
||||
{
|
||||
if (preg_match('/^[a-zA-Z0-9._-]+$/', $value)) {
|
||||
return $value;
|
||||
}
|
||||
return '"' . addcslashes($value, '"\\') . '"';
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove lines we added for this hostname (MAGE_RUN_CODE and MAGE_RUN_TYPE).
|
||||
*/
|
||||
private function removeTunnelMappingFromContent(string $content, string $hostname): string
|
||||
{
|
||||
$hostPat = preg_quote($this->escapeNginxMapKey($hostname), '/');
|
||||
$comment = preg_quote(self::LINE_COMMENT, '/');
|
||||
$content = preg_replace(
|
||||
'/^\s*' . $hostPat . '\s+\S+\s*;\s*' . $comment . '\s*$/m',
|
||||
'',
|
||||
$content
|
||||
) ?? $content;
|
||||
$content = preg_replace(
|
||||
'/^\s*' . $hostPat . '\s+\S+\s*;\s*' . $comment . '\s*\}\s*$/m',
|
||||
'}',
|
||||
$content
|
||||
) ?? $content;
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a line before the closing `}` of map $source $resultVar { ... }
|
||||
*
|
||||
* @throws LocalizedException
|
||||
*/
|
||||
private function insertIntoNamedMap(string $content, string $resultVar, string $line): string
|
||||
{
|
||||
$source = preg_quote($this->getMapSourceVar(), '/');
|
||||
$result = preg_quote($resultVar, '/');
|
||||
if (!preg_match('/map\s+\$' . $source . '\s+\$' . $result . '\s*\{/s', $content, $m, PREG_OFFSET_CAPTURE)) {
|
||||
throw new LocalizedException(
|
||||
__(
|
||||
'Could not find map block "map $%1 $%2 { ... }" in the global map file. Add it to your Ansible template (see MageSail docs).',
|
||||
$this->getMapSourceVar(),
|
||||
$resultVar
|
||||
)
|
||||
);
|
||||
}
|
||||
$openBracePos = $m[0][1] + \strlen($m[0][0]) - 1;
|
||||
$closePos = $this->findMatchingClosingBrace($content, $openBracePos);
|
||||
if ($closePos === null) {
|
||||
throw new LocalizedException(__('Unclosed map block for $%1 in global map file.', $resultVar));
|
||||
}
|
||||
$inner = substr($content, $openBracePos + 1, $closePos - $openBracePos - 1);
|
||||
if (str_contains($inner, $line)) {
|
||||
return $content;
|
||||
}
|
||||
$before = substr($content, 0, $closePos);
|
||||
$after = substr($content, $closePos);
|
||||
return $before . "\n" . $line . "\n" . $after;
|
||||
}
|
||||
|
||||
private function findMatchingClosingBrace(string $content, int $openBraceIndex): ?int
|
||||
{
|
||||
$len = \strlen($content);
|
||||
$depth = 0;
|
||||
for ($i = $openBraceIndex; $i < $len; $i++) {
|
||||
$c = $content[$i];
|
||||
if ($c === '{') {
|
||||
$depth++;
|
||||
} elseif ($c === '}') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MageSail\Magesail\Model;
|
||||
|
||||
/**
|
||||
* Parses NGINX map blocks into structured data for GUI editing.
|
||||
*/
|
||||
class NginxMapParser
|
||||
{
|
||||
/**
|
||||
* @return array{blocks: array<int, array{source: string, target: string, startLine: int, endLine: int, entries: array<int, array{hostname: string, value: string, line: int, comment?: string}>}>}
|
||||
*/
|
||||
public function parse(string $content): array
|
||||
{
|
||||
$lines = explode("\n", $content);
|
||||
$blocks = [];
|
||||
$i = 0;
|
||||
$len = \count($lines);
|
||||
while ($i < $len) {
|
||||
if (preg_match('/^\s*map\s+\$(\w+)\s+\$(\w+)\s*\{/', $lines[$i], $m)) {
|
||||
$block = [
|
||||
'source' => $m[1],
|
||||
'target' => $m[2],
|
||||
'startLine' => $i,
|
||||
'endLine' => $i,
|
||||
'entries' => [],
|
||||
];
|
||||
$openBrace = $i;
|
||||
$depth = 0;
|
||||
$inBlock = false;
|
||||
for ($j = $i; $j < $len; $j++) {
|
||||
$line = $lines[$j];
|
||||
if (str_contains($line, '{')) {
|
||||
$depth++;
|
||||
$inBlock = true;
|
||||
}
|
||||
if (str_contains($line, '}')) {
|
||||
$depth--;
|
||||
if ($depth === 0 && $inBlock) {
|
||||
$block['endLine'] = $j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($inBlock && $j > $openBrace) {
|
||||
$trimmed = trim($line);
|
||||
if ($trimmed === '' || $trimmed === '}') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^default\s+(\S+)\s*;\s*(.*)$/', $trimmed, $defaultMatch)) {
|
||||
$block['default'] = trim($defaultMatch[1], '"\'');
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^(\S+)\s+(\S+)\s*;\s*(.*)$/', $trimmed, $entry)) {
|
||||
$hostname = trim($entry[1], '"\'');
|
||||
$value = trim($entry[2], '"\'');
|
||||
$comment = trim($entry[3] ?? '');
|
||||
$block['entries'][] = [
|
||||
'hostname' => $hostname,
|
||||
'value' => $value,
|
||||
'line' => $j,
|
||||
'comment' => $comment !== '' ? $comment : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($block['endLine'] > $block['startLine']) {
|
||||
$blocks[] = $block;
|
||||
}
|
||||
$i = $block['endLine'] + 1;
|
||||
} else {
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
return ['blocks' => $blocks];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild file content from parsed blocks, preserving non-map content and original map order/position.
|
||||
*
|
||||
* Each block must include startLine, endLine from {@see parse()} (GUI saves merge file metadata with edited entries).
|
||||
*/
|
||||
public function rebuild(string $originalContent, array $parsedBlocks): string
|
||||
{
|
||||
$lines = explode("\n", $originalContent);
|
||||
$len = \count($lines);
|
||||
/** @var array<int, array<string, mixed>> $blockAtLine */
|
||||
$blockAtLine = [];
|
||||
foreach ($parsedBlocks['blocks'] as $block) {
|
||||
if (!isset($block['startLine'], $block['endLine'])) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Each map block must include startLine and endLine (re-parse the file or use merged save data).'
|
||||
);
|
||||
}
|
||||
$blockAtLine[(int) $block['startLine']] = $block;
|
||||
}
|
||||
ksort($blockAtLine, SORT_NUMERIC);
|
||||
$output = [];
|
||||
$i = 0;
|
||||
while ($i < $len) {
|
||||
if (isset($blockAtLine[$i])) {
|
||||
$block = $blockAtLine[$i];
|
||||
foreach ($this->renderMapBlockLines($lines, $block) as $line) {
|
||||
$output[] = $line;
|
||||
}
|
||||
$i = (int) $block['endLine'] + 1;
|
||||
continue;
|
||||
}
|
||||
$output[] = $lines[$i];
|
||||
$i++;
|
||||
}
|
||||
return implode("\n", $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $block
|
||||
* @return list<string>
|
||||
*/
|
||||
private function renderMapBlockLines(array $fileLines, array $block): array
|
||||
{
|
||||
$start = (int) $block['startLine'];
|
||||
$indent = $this->detectIndent($fileLines, $start);
|
||||
$out = [];
|
||||
$out[] = sprintf('%smap $%s $%s {', $indent, $block['source'], $block['target']);
|
||||
$defaultValue = $block['default'] ?? "''";
|
||||
$out[] = sprintf('%s default %s;', $indent, $defaultValue);
|
||||
foreach ($block['entries'] as $entry) {
|
||||
if (!\is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
$hostname = $this->escapeIfNeeded((string) ($entry['hostname'] ?? ''));
|
||||
$value = $this->escapeIfNeeded((string) ($entry['value'] ?? ''));
|
||||
$comment = !empty($entry['comment']) ? ' ' . (string) $entry['comment'] : '';
|
||||
$out[] = sprintf('%s %s %s;%s', $indent, $hostname, $value, $comment);
|
||||
}
|
||||
$out[] = $indent . '}';
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function detectIndent(array $lines, int $lineNum): string
|
||||
{
|
||||
if ($lineNum < \count($lines)) {
|
||||
$line = $lines[$lineNum];
|
||||
if (preg_match('/^(\s*)/', $line, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function escapeIfNeeded(string $value): string
|
||||
{
|
||||
if (preg_match('/^[a-zA-Z0-9._-]+$/', $value)) {
|
||||
return $value;
|
||||
}
|
||||
return '"' . addcslashes($value, '"\\') . '"';
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace MageSail\Magesail\Model;
|
||||
|
||||
use Magento\Framework\App\Config\ScopeConfigInterface;
|
||||
use Magento\Framework\App\Filesystem\DirectoryList;
|
||||
use Magento\Framework\Shell;
|
||||
|
||||
@@ -11,9 +12,13 @@ use Magento\Framework\Shell;
|
||||
*/
|
||||
class TunnelStatus
|
||||
{
|
||||
private const XML_AUTO_RESTART = 'magesail/settings/auto_restart';
|
||||
|
||||
public function __construct(
|
||||
private readonly Shell $shell,
|
||||
private readonly DirectoryList $directoryList
|
||||
private readonly DirectoryList $directoryList,
|
||||
private readonly ScopeConfigInterface $scopeConfig,
|
||||
private readonly TunnelStoreProvisioner $tunnelStoreProvisioner
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -47,6 +52,7 @@ class TunnelStatus
|
||||
return ['running' => false, 'pid' => null, 'key' => null];
|
||||
}
|
||||
if (!$this->isProcessAlive($pid)) {
|
||||
$this->maybeTeardownTunnelStoreForStaleProcess();
|
||||
@unlink($pidFile);
|
||||
$kf = $this->getKeyFilePath();
|
||||
if (\is_file($kf)) {
|
||||
@@ -93,6 +99,7 @@ class TunnelStatus
|
||||
}
|
||||
$pid = (int) trim((string) file_get_contents($pidFile));
|
||||
if ($pid <= 0 || !$this->isProcessAlive($pid)) {
|
||||
$this->maybeTeardownTunnelStoreForStaleProcess();
|
||||
@unlink($pidFile);
|
||||
$keyFile = $this->getKeyFilePath();
|
||||
if (\is_file($keyFile)) {
|
||||
@@ -100,4 +107,16 @@ class TunnelStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the tunnel process is gone and auto-restart is off, remove the tunnel store view.
|
||||
*/
|
||||
private function maybeTeardownTunnelStoreForStaleProcess(): void
|
||||
{
|
||||
$autoRestart = (bool) $this->scopeConfig->getValue(self::XML_AUTO_RESTART);
|
||||
if ($autoRestart) {
|
||||
return;
|
||||
}
|
||||
$this->tunnelStoreProvisioner->teardownAfterStaleProcess();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MageSail\Magesail\Model;
|
||||
|
||||
use Magento\Framework\App\Filesystem\DirectoryList;
|
||||
use Magento\Framework\Serialize\Serializer\Json;
|
||||
|
||||
/**
|
||||
* Persists tunnel store metadata in var/ for teardown and admin display.
|
||||
*/
|
||||
class TunnelStoreState
|
||||
{
|
||||
private const FILENAME = 'magesail_tunnel_store.json';
|
||||
|
||||
public function __construct(
|
||||
private readonly DirectoryList $directoryList,
|
||||
private readonly Json $json
|
||||
) {
|
||||
}
|
||||
|
||||
public function getFilePath(): string
|
||||
{
|
||||
return $this->directoryList->getPath(DirectoryList::VAR_DIR) . '/' . self::FILENAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 read(): ?array
|
||||
{
|
||||
$path = $this->getFilePath();
|
||||
if (!\is_readable($path)) {
|
||||
return null;
|
||||
}
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
/** @var array $data */
|
||||
$data = $this->json->unserialize($raw);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return null;
|
||||
}
|
||||
if (!isset($data['store_id'], $data['store_code'])) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'store_id' => (int) $data['store_id'],
|
||||
'store_code' => (string) $data['store_code'],
|
||||
'unsecure_base_url' => (string) ($data['unsecure_base_url'] ?? ''),
|
||||
'secure_base_url' => (string) ($data['secure_base_url'] ?? ''),
|
||||
'use_secure_urls' => (bool) ($data['use_secure_urls'] ?? false),
|
||||
'nginx_pending_reload' => (bool) ($data['nginx_pending_reload'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{store_id: int, store_code: string, unsecure_base_url: string, secure_base_url: string, use_secure_urls: bool, nginx_pending_reload?: bool} $data
|
||||
*/
|
||||
public function write(array $data): void
|
||||
{
|
||||
$path = $this->getFilePath();
|
||||
$payload = $this->json->serialize($data);
|
||||
if (file_put_contents($path, $payload, LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Could not write tunnel store state file.');
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$path = $this->getFilePath();
|
||||
if (\is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MageSail\Magesail\Plugin\App\Request;
|
||||
|
||||
use Magento\Framework\App\Request\Http;
|
||||
use Magento\Store\Model\StoreManager;
|
||||
|
||||
/**
|
||||
* When request has X-MageSail-Store-Code header or magesail_store query param (from tunnel proxy),
|
||||
* make getServerValue return it for MAGE_RUN_CODE and 'store' for MAGE_RUN_TYPE so Magento serves that store.
|
||||
*/
|
||||
class StoreFromMageSailHeaderPlugin
|
||||
{
|
||||
private const HEADER_STORE_CODE = 'X-MageSail-Store-Code';
|
||||
private const QUERY_PARAM_STORE = 'magesail_store';
|
||||
|
||||
/**
|
||||
* Around getServerValue: if MageSail store header is set, return it for MAGE_RUN_CODE / MAGE_RUN_TYPE.
|
||||
*
|
||||
* @param Http $subject
|
||||
* @param callable $proceed
|
||||
* @param string|null $name
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
public function aroundGetServerValue(Http $subject, callable $proceed, $name = null, $default = null)
|
||||
{
|
||||
$storeCode = $this->getStoreCodeFromRequest($subject);
|
||||
if ($storeCode === null) {
|
||||
return $proceed($name, $default);
|
||||
}
|
||||
if ($name === StoreManager::PARAM_RUN_CODE) {
|
||||
return $storeCode;
|
||||
}
|
||||
if ($name === StoreManager::PARAM_RUN_TYPE) {
|
||||
return 'store';
|
||||
}
|
||||
return $proceed($name, $default);
|
||||
}
|
||||
|
||||
private function getStoreCodeFromRequest(Http $request): ?string
|
||||
{
|
||||
$value = $request->getParam(self::QUERY_PARAM_STORE);
|
||||
if ($value !== null && trim((string) $value) !== '') {
|
||||
return trim((string) $value);
|
||||
}
|
||||
$value = $request->getHeader(self::HEADER_STORE_CODE);
|
||||
if ($value !== false && $value !== null && trim((string) $value) !== '') {
|
||||
return trim((string) $value);
|
||||
}
|
||||
$value = $request->getServer('HTTP_X_MAGESAIL_STORE_CODE');
|
||||
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
|
||||
return trim((string) $value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MageSail\Magesail\Plugin\FrontController;
|
||||
|
||||
use Magento\Framework\App\FrontController;
|
||||
use Magento\Framework\App\RequestInterface;
|
||||
|
||||
/**
|
||||
* Run before Store RequestPreprocessor (sortOrder 10 < 50).
|
||||
* Ensure magesail_store is on the request from superglobals so base-URL redirect skip works
|
||||
* even if the Request object was built before query params were fully available.
|
||||
*/
|
||||
class EnsureTunnelParamPlugin
|
||||
{
|
||||
private const QUERY_PARAM_STORE = 'magesail_store';
|
||||
private const SERVER_HEADER = 'HTTP_X_MAGESAIL_STORE_CODE';
|
||||
|
||||
public function aroundDispatch(
|
||||
FrontController $subject,
|
||||
\Closure $proceed,
|
||||
RequestInterface $request
|
||||
) {
|
||||
$this->ensureTunnelParamOnRequest($request);
|
||||
return $proceed($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* If tunnel indicator is in $_GET or REQUEST_URI or header, set it on the request
|
||||
* so later plugins (BaseUrlChecker, RequestPreprocessor skip) see it.
|
||||
*/
|
||||
private function ensureTunnelParamOnRequest(RequestInterface $request): void
|
||||
{
|
||||
$value = $this->getTunnelStoreFromSuperglobals();
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
if (!$request instanceof \Magento\Framework\App\Request\Http) {
|
||||
return;
|
||||
}
|
||||
if ($request->getParam(self::QUERY_PARAM_STORE) !== null) {
|
||||
return;
|
||||
}
|
||||
$request->setParam(self::QUERY_PARAM_STORE, $value);
|
||||
}
|
||||
|
||||
private function getTunnelStoreFromSuperglobals(): ?string
|
||||
{
|
||||
if (!empty($_GET[self::QUERY_PARAM_STORE])) {
|
||||
$v = trim((string) $_GET[self::QUERY_PARAM_STORE]);
|
||||
if ($v !== '') {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
if ($uri !== '' && str_contains($uri, self::QUERY_PARAM_STORE . '=')) {
|
||||
$query = str_contains($uri, '?') ? substr($uri, strpos($uri, '?') + 1) : '';
|
||||
parse_str($query, $params);
|
||||
$v = $params[self::QUERY_PARAM_STORE] ?? '';
|
||||
if ($v !== '' && trim((string) $v) !== '') {
|
||||
return trim((string) $v);
|
||||
}
|
||||
}
|
||||
$h = $_SERVER[self::SERVER_HEADER] ?? '';
|
||||
if ($h !== '' && trim((string) $h) !== '') {
|
||||
return trim((string) $h);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MageSail\Magesail\Plugin\Store;
|
||||
|
||||
use Magento\Framework\App\Request\Http;
|
||||
use Magento\Framework\App\RequestInterface;
|
||||
use Magento\Store\Model\BaseUrlChecker;
|
||||
|
||||
/**
|
||||
* When request has magesail_store (tunnel proxy), consider base URL valid so no 301 is issued.
|
||||
*/
|
||||
class BaseUrlCheckerTunnelPlugin
|
||||
{
|
||||
private const QUERY_PARAM_STORE = 'magesail_store';
|
||||
private const SERVER_HEADER = 'HTTP_X_MAGESAIL_STORE_CODE';
|
||||
|
||||
/** @var RequestInterface */
|
||||
private $request;
|
||||
|
||||
public function __construct(RequestInterface $request)
|
||||
{
|
||||
$this->request = $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable redirect-to-base when tunnel param is present so RequestPreprocessor skips the block.
|
||||
*
|
||||
* @param BaseUrlChecker $subject
|
||||
* @return array|null [false] to disable, null to proceed
|
||||
*/
|
||||
public function beforeIsEnabled(BaseUrlChecker $subject): ?array
|
||||
{
|
||||
if ($this->request instanceof Http && $this->hasTunnelStore($this->request)) {
|
||||
return [false];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* If tunnel store indicator is present, treat URL as valid (skip redirect).
|
||||
*
|
||||
* @param BaseUrlChecker $subject
|
||||
* @param callable $proceed
|
||||
* @param array $uri
|
||||
* @param Http $request
|
||||
* @return bool
|
||||
*/
|
||||
public function aroundExecute(BaseUrlChecker $subject, callable $proceed, $uri, $request): bool
|
||||
{
|
||||
if ($request instanceof Http && $this->hasTunnelStore($request)) {
|
||||
return true;
|
||||
}
|
||||
return $proceed($uri, $request);
|
||||
}
|
||||
|
||||
private function hasTunnelStore(Http $request): bool
|
||||
{
|
||||
$value = $request->getParam(self::QUERY_PARAM_STORE);
|
||||
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
|
||||
return true;
|
||||
}
|
||||
if (method_exists($request, 'getQueryValue')) {
|
||||
$value = $request->getQueryValue(self::QUERY_PARAM_STORE);
|
||||
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$uri = $request->getRequestUri();
|
||||
if ($uri !== null && str_contains((string) $uri, self::QUERY_PARAM_STORE . '=')) {
|
||||
return true;
|
||||
}
|
||||
$value = $request->getServer(self::SERVER_HEADER);
|
||||
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: superglobals (e.g. if request was built before query string was available)
|
||||
return $this->hasTunnelStoreFromSuperglobals();
|
||||
}
|
||||
|
||||
private function hasTunnelStoreFromSuperglobals(): bool
|
||||
{
|
||||
if (!empty($_GET[self::QUERY_PARAM_STORE]) && trim((string) $_GET[self::QUERY_PARAM_STORE]) !== '') {
|
||||
return true;
|
||||
}
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
if ($uri !== '' && str_contains($uri, self::QUERY_PARAM_STORE . '=')) {
|
||||
return true;
|
||||
}
|
||||
$h = $_SERVER[self::SERVER_HEADER] ?? '';
|
||||
return $h !== '' && trim((string) $h) !== '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MageSail\Magesail\Plugin\Store;
|
||||
|
||||
use Magento\Framework\App\FrontController;
|
||||
use Magento\Framework\App\RequestInterface;
|
||||
use Magento\Framework\App\ResponseInterface;
|
||||
|
||||
/**
|
||||
* When request has X-MageSail-Store-Code (tunnel proxy), skip the base URL redirect
|
||||
* so developers can use http://127.0.0.1:port without being redirected to the store's base URL.
|
||||
*/
|
||||
class SkipBaseUrlRedirectForTunnelPlugin
|
||||
{
|
||||
/** $_SERVER key for X-MageSail-Store-Code (web server passes headers as HTTP_*). */
|
||||
private const SERVER_HEADER_STORE_CODE = 'HTTP_X_MAGESAIL_STORE_CODE';
|
||||
/** Query param fallback when server strips custom headers (proxy appends this). */
|
||||
private const QUERY_PARAM_STORE = 'magesail_store';
|
||||
|
||||
/**
|
||||
* Skip base URL redirect when tunnel store header is present.
|
||||
*
|
||||
* @param \Magento\Store\App\FrontController\Plugin\RequestPreprocessor $subject
|
||||
* @param callable $proceed
|
||||
* @param FrontController $frontController
|
||||
* @param \Closure $chain
|
||||
* @param RequestInterface $request
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function aroundDispatch(
|
||||
\Magento\Store\App\FrontController\Plugin\RequestPreprocessor $subject,
|
||||
callable $proceed,
|
||||
FrontController $frontController,
|
||||
\Closure $chain,
|
||||
RequestInterface $request
|
||||
) {
|
||||
if ($this->hasTunnelStoreHeader($request)) {
|
||||
$request->setDispatched(false);
|
||||
return $chain($request);
|
||||
}
|
||||
return $proceed($frontController, $chain, $request);
|
||||
}
|
||||
|
||||
private function hasTunnelStoreHeader(RequestInterface $request): bool
|
||||
{
|
||||
$value = $request->getParam(self::QUERY_PARAM_STORE);
|
||||
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
|
||||
return true;
|
||||
}
|
||||
if (!$request instanceof \Magento\Framework\App\Request\Http) {
|
||||
return false;
|
||||
}
|
||||
/** @var \Magento\Framework\App\Request\Http $request */
|
||||
if (method_exists($request, 'getQueryValue')) {
|
||||
$value = $request->getQueryValue(self::QUERY_PARAM_STORE);
|
||||
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$uri = $request->getRequestUri();
|
||||
if ($uri !== null && str_contains((string) $uri, self::QUERY_PARAM_STORE . '=')) {
|
||||
return true;
|
||||
}
|
||||
$value = $request->getServer(self::SERVER_HEADER_STORE_CODE);
|
||||
return $value !== null && $value !== '' && trim((string) $value) !== '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
# Tunnel and base URL redirect (301)
|
||||
|
||||
## Tunnel store view (MageSail admin)
|
||||
|
||||
When you start the tunnel from **Admin → Holesail Tunnel**, you must enter **base URLs** that match the hostname shown in the browser when using [holesail-browser](https://git.ssh.surf/snxraven/holesail-browser) (your virtual host there and Magento’s **Base URL** / **Base URL (Secure)** for the tunnel store must align, including `http` vs `https`).
|
||||
|
||||
MageSail then:
|
||||
|
||||
1. Creates or updates a dedicated store view with code **`magesail_tunnel`** on the default website’s default store group.
|
||||
2. Sets scoped config: `web/unsecure/base_url`, `web/secure/base_url`, and (if you check the box) `web/secure/use_in_frontend` / `web/secure/use_in_adminhtml`.
|
||||
3. Writes `var/magesail_tunnel_store.json` so the same store can be torn down when the tunnel stops.
|
||||
4. **Stop tunnel** in admin → that store view is deleted (unless it is the website default—see logs). If the tunnel **process dies** and **auto-restart** is **off**, stale cleanup also removes the store; if **auto-restart** is **on**, the store and URLs stay so cron can restart the process without re-entering URLs.
|
||||
|
||||
**Proxy / holesail-browser:** Forward the tunnel hostname Magento expects, and select the tunnel store so links and redirects stay on that host. Either:
|
||||
|
||||
- Header: `X-MageSail-Store-Code: magesail_tunnel`, or
|
||||
- Query: `magesail_store=magesail_tunnel`, or
|
||||
- Native Magento: `___store=magesail_tunnel`
|
||||
|
||||
See **StoreFromMageSailHeaderPlugin** and related frontend plugins in this module.
|
||||
|
||||
### NGINX global map (JetRails / Ansible style)
|
||||
|
||||
If your stack uses the standard global map:
|
||||
|
||||
```nginx
|
||||
map $http_host $MAGE_RUN_CODE {
|
||||
default '';
|
||||
}
|
||||
map $http_host $MAGE_RUN_TYPE {
|
||||
default '';
|
||||
}
|
||||
```
|
||||
|
||||
configure **Stores → Configuration → Holesail Tunnel → NGINX Configuration**:
|
||||
|
||||
- **Global map file path** — absolute path to that file (the one managed from Ansible).
|
||||
- Defaults: source `http_host`, maps `MAGE_RUN_CODE` and `MAGE_RUN_TYPE`, type value `store`.
|
||||
|
||||
When a tunnel starts, MageSail inserts **two** lines (before each map’s closing `}`):
|
||||
|
||||
- `test.magento.local magesail_tunnel; # MageSail tunnel`
|
||||
- `test.magento.local store; # MageSail tunnel`
|
||||
|
||||
So PHP-FPM receives `MAGE_RUN_CODE` / `MAGE_RUN_TYPE` for the tunneled host without hand-editing Ansible. On tunnel stop, those lines are removed.
|
||||
|
||||
**Validation / reload:** MageSail runs `nginx -t -c <minimal.conf>` then `nginx -s reload`. The minimal config contains a single `pid` (writable path), `events {}`, and `http { include <your global map>; }`—it does **not** load `/etc/nginx/nginx.conf`, so you avoid duplicate `pid` errors and `/run/nginx.pid` permission issues. **Temporary PID file** in config sets that `pid` path (default under system temp). This checks **map file syntax**, not SSL vhosts. Reload still targets the real master process.
|
||||
|
||||
Commands run via `proc_open` so stderr is captured.
|
||||
|
||||
**Permissions (PHP-FPM vs SSH user):** Admin runs as the **web/PHP user** (e.g. `www-data`, `nginx`), not your SSH login (`s1x64._Quae0`). The map file must be **writable by that user**. Typical patterns:
|
||||
|
||||
- File is `664` and group `jetrails`: add PHP user to that group, then restart PHP-FPM:
|
||||
`usermod -aG jetrails www-data` (replace `www-data` with your pool user from `grep '^user' /etc/php-fpm.d/www.conf`).
|
||||
- Or use ACL on the file only:
|
||||
`setfacl -m u:www-data:rw /etc/nginx/conf.d/global-map.conf`
|
||||
|
||||
MageSail only requires the **directory** writable when the map file does **not** exist yet; for an existing `global-map.conf`, only the **file** must be writable.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
When developers use the MageSail tunnel (e.g. `http://127.0.0.1:8086/`), Magento should **not** redirect to the store’s base URL (e.g. `https://s1.x64.world/`). The request should be handled in place and return 200.
|
||||
|
||||
## Where the redirect comes from
|
||||
|
||||
The only place in Magento core that performs the “redirect to base URL” is:
|
||||
|
||||
- **`Magento\Store\App\FrontController\Plugin\RequestPreprocessor::aroundDispatch`**
|
||||
|
||||
It runs as a plugin on **`Magento\Framework\App\FrontController::dispatch`** (frontend only: `module-store/etc/frontend/di.xml`, sortOrder 50).
|
||||
|
||||
Logic:
|
||||
|
||||
1. If `getBaseUrlChecker()->isEnabled()` and request is not POST, it gets the store base URL.
|
||||
2. It parses the base URL and calls `getBaseUrlChecker()->execute($uri, $request)`.
|
||||
3. If `execute()` returns `false` (URL “invalid”), it builds a redirect URL via `$this->_url->getRedirectUrl($this->_url->getDirectUrl(...))` and returns a response with `setRedirect($redirectUrl, 301 or 302)`.
|
||||
|
||||
- **`Magento\Store\Model\BaseUrlChecker`**
|
||||
|
||||
- `isEnabled()`: reads `web/url/redirect_to_base` (store scope).
|
||||
- `execute($uri, $request)`: compares request scheme, host, and path to the parsed base URL; returns true only if they match.
|
||||
|
||||
So the 301 is issued by **RequestPreprocessor** when **BaseUrlChecker::isEnabled()** is true and **BaseUrlChecker::execute()** returns false (request host/scheme/path don’t match the store base URL).
|
||||
|
||||
## Request flow (relevant part)
|
||||
|
||||
1. `Magento\Framework\App\Http::launch()`:
|
||||
- Resolves area from `$this->_request->getFrontName()`.
|
||||
- Loads area config (e.g. frontend).
|
||||
- Gets `FrontControllerInterface` and calls `$frontController->dispatch($this->_request)`.
|
||||
|
||||
2. **FrontController::dispatch** is wrapped by plugins (frontend). One of them is **RequestPreprocessor** (sortOrder 50). When the chain reaches it, **RequestPreprocessor::aroundDispatch** runs and may return the redirect response **before** the actual router loop runs.
|
||||
|
||||
3. The **Request** object is the same `Http` instance from bootstrap; it is populated from PHP (e.g. `$_GET`, `$_SERVER['REQUEST_URI']`) by the Laminas/Magento request implementation. So in normal setups, `?magesail_store=default` should be visible via `$request->getParam('magesail_store')` and `getRequestUri()`.
|
||||
|
||||
## What can still cause 301
|
||||
|
||||
1. **Redirect happens before PHP**
|
||||
If the request hits a reverse proxy (e.g. nginx, JetRails) that does **HTTP → HTTPS** or “redirect to canonical host” **before** passing the request to PHP, Magento never runs and the 301 is from the edge. Our plugins cannot change that. Fix: proxy to an HTTP backend that does not redirect (e.g. different vhost/port or PHP built-in server for dev), or configure the edge to not redirect when e.g. `X-Forwarded-Proto: https` is present.
|
||||
|
||||
2. **Request doesn’t contain `magesail_store` when plugins run**
|
||||
In theory the request is built once and should include query params. If in some deployment the request is built or modified in a way that doesn’t expose `magesail_store` (or the header) when our plugins run, we would still enter the redirect block. We mitigate this with:
|
||||
- An early **FrontController** plugin that copies the tunnel indicator from `$_GET` / `REQUEST_URI` / header onto the request.
|
||||
- Fallbacks in our **BaseUrlChecker** and **SkipBaseUrlRedirect** plugins to superglobals.
|
||||
|
||||
## MageSail’s approach (no base URL redirect for tunnel)
|
||||
|
||||
We use several layers so that when the tunnel is detected, the base-URL redirect is skipped:
|
||||
|
||||
1. **`EnsureTunnelParamPlugin`** (plugin on `FrontController::dispatch`, sortOrder 10)
|
||||
Runs before RequestPreprocessor (50). If `magesail_store` (or header) is in `$_GET`, `REQUEST_URI`, or `HTTP_X_MAGESAIL_STORE_CODE`, it **sets** `magesail_store` on the request. So later code always sees the param.
|
||||
|
||||
2. **`SkipBaseUrlRedirectForTunnelPlugin`** (plugin on `RequestPreprocessor::aroundDispatch`, sortOrder 1)
|
||||
If the request has the tunnel store (param or header), we **do not** call the original RequestPreprocessor; we call the next closure in the FrontController chain (`$chain($request)`), so the redirect block is never run.
|
||||
|
||||
3. **`BaseUrlCheckerTunnelPlugin`**
|
||||
- **beforeIsEnabled**: if the request (or superglobals) has the tunnel indicator, return `false` so RequestPreprocessor’s condition `getBaseUrlChecker()->isEnabled()` is false and the whole redirect block is skipped.
|
||||
- **aroundExecute**: if the request (or superglobals) has the tunnel indicator, return `true` (URL considered valid) so even if the block is entered, no redirect is issued.
|
||||
|
||||
4. **StoreFromMageSailHeaderPlugin** (on `Request::getServerValue`)
|
||||
Makes Magento resolve the store from `magesail_store` or `X-MageSail-Store-Code`, so the correct store is used and URLs (including any redirect URL) are for that store. This does not by itself prevent the redirect; it only ensures that when redirect *is* built, it uses the right store (and we still avoid building it via the plugins above).
|
||||
|
||||
## Summary
|
||||
|
||||
- The **only** Magento code that performs the base-URL 301 is **RequestPreprocessor**, using **BaseUrlChecker::isEnabled()** and **BaseUrlChecker::execute()**.
|
||||
- We disable that redirect for tunnel traffic by: (1) ensuring the request has the tunnel param early, (2) skipping RequestPreprocessor’s redirect when tunnel is present, (3) making BaseUrlChecker report “enabled = false” and “execute = true” when tunnel is present.
|
||||
- If 301 still appears, it is likely from the **web server / reverse proxy** (e.g. HTTP→HTTPS) before the request reaches PHP; in that case the hosting layer must be adjusted or the tunnel must target an endpoint that does not perform that redirect.
|
||||
@@ -3,5 +3,6 @@
|
||||
<menu>
|
||||
<add id="MageSail_Magesail::tunnel" title="Holesail Tunnel" module="MageSail_Magesail" sortOrder="10" parent="Magento_Backend::system" resource="MageSail_Magesail::tunnel" />
|
||||
<add id="MageSail_Magesail::tunnel_index" title="Manage Tunnel" module="MageSail_Magesail" sortOrder="10" action="magesail/tunnel/index" resource="MageSail_Magesail::tunnel" parent="MageSail_Magesail::tunnel" />
|
||||
<add id="MageSail_Magesail::nginx_map" title="NGINX Global Map" module="MageSail_Magesail" sortOrder="20" action="magesail/nginx/map" resource="MageSail_Magesail::config" parent="MageSail_Magesail::tunnel" />
|
||||
</menu>
|
||||
</config>
|
||||
|
||||
@@ -30,6 +30,47 @@
|
||||
<comment>If Yes, daily cron restarts the tunnel when the process is no longer running (uses port/secure above).</comment>
|
||||
</field>
|
||||
</group>
|
||||
<group id="nginx" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="1">
|
||||
<label>NGINX Configuration</label>
|
||||
<field id="global_map_path" translate="label" type="text" sortOrder="10" showInDefault="1">
|
||||
<label>Global map file path</label>
|
||||
<comment>Absolute path to your Ansible global map (e.g. the file that contains map $http_host $MAGE_RUN_CODE). MageSail appends lines inside those blocks—do not use a separate MageSail-only map unless your vhost includes it.</comment>
|
||||
</field>
|
||||
<field id="nginx_binary" translate="label" type="text" sortOrder="15" showInDefault="1">
|
||||
<label>nginx binary path</label>
|
||||
<comment>Default "nginx" (PATH). Use full path if PHP cannot find nginx, e.g. /usr/sbin/nginx. Used for nginx -t and reload.</comment>
|
||||
</field>
|
||||
<field id="test_pid_path" translate="label" type="text" sortOrder="16" showInDefault="1">
|
||||
<label>Temporary PID file (nginx -t minimal config)</label>
|
||||
<comment>MageSail validates syntax with a tiny generated nginx config (not main nginx.conf) that includes only your global map—avoids duplicate pid and /run/nginx.pid permission errors. Leave empty to use a unique file under system temp per validation (writable by PHP; avoids a stale root-owned fixed path). Set explicitly only if you need a fixed path.</comment>
|
||||
</field>
|
||||
<field id="reload_wrapper_script" translate="label" type="text" sortOrder="17" showInDefault="1">
|
||||
<label>Reload wrapper script (optional)</label>
|
||||
<comment>Absolute path to an executable that reloads nginx (e.g. script calling sudo -n /usr/sbin/nginx -s reload). Leave empty to run nginx -s reload as PHP (often fails with kill EPERM). See app/code/MageSail/Magesail/scripts/README-NGINX-RELOAD.md and nginx-reload-probe.php.</comment>
|
||||
</field>
|
||||
<field id="map_source_variable" translate="label" type="text" sortOrder="20" showInDefault="1">
|
||||
<label>Map source variable</label>
|
||||
<comment>First argument of map directives (without $). Must match your global map, usually http_host.</comment>
|
||||
</field>
|
||||
<field id="map_run_code_variable" translate="label" type="text" sortOrder="30" showInDefault="1">
|
||||
<label>MAGE_RUN_CODE map variable name</label>
|
||||
<comment>Second map name (without $), usually MAGE_RUN_CODE. MageSail inserts "hostname storecode;" here.</comment>
|
||||
</field>
|
||||
<field id="map_run_type_variable" translate="label" type="text" sortOrder="40" showInDefault="1">
|
||||
<label>MAGE_RUN_TYPE map variable name</label>
|
||||
<comment>Second map name (without $), usually MAGE_RUN_TYPE. MageSail inserts "hostname store;" here so Magento runs in store scope.</comment>
|
||||
</field>
|
||||
<field id="map_run_type_value" translate="label" type="text" sortOrder="50" showInDefault="1">
|
||||
<label>Value for MAGE_RUN_TYPE line</label>
|
||||
<comment>Usually store when using a store view code in MAGE_RUN_CODE.</comment>
|
||||
</field>
|
||||
<field id="disable_base_url_redirect" translate="label" type="select" sortOrder="60" showInDefault="1">
|
||||
<label>Disable base URL redirect for tunnel</label>
|
||||
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
|
||||
<default>1</default>
|
||||
<comment>If Yes, disables web/url/redirect_to_base for the tunnel store view to prevent redirects.</comment>
|
||||
</field>
|
||||
</group>
|
||||
</section>
|
||||
</system>
|
||||
</config>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0"?>
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
|
||||
<default>
|
||||
<magesail>
|
||||
<nginx>
|
||||
<map_source_variable>http_host</map_source_variable>
|
||||
<map_run_code_variable>MAGE_RUN_CODE</map_run_code_variable>
|
||||
<map_run_type_variable>MAGE_RUN_TYPE</map_run_type_variable>
|
||||
<map_run_type_value>store</map_run_type_value>
|
||||
</nginx>
|
||||
</magesail>
|
||||
</default>
|
||||
</config>
|
||||
@@ -1,3 +1,6 @@
|
||||
<?xml version="1.0"?>
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
|
||||
<type name="Magento\Framework\App\Request\Http">
|
||||
<plugin name="magesail_store_from_header" type="MageSail\Magesail\Plugin\App\Request\StoreFromMageSailHeaderPlugin"/>
|
||||
</type>
|
||||
</config>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0"?>
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
|
||||
<type name="Magento\Framework\App\FrontController">
|
||||
<plugin name="magesail_ensure_tunnel_param" type="MageSail\Magesail\Plugin\FrontController\EnsureTunnelParamPlugin" sortOrder="10"/>
|
||||
</type>
|
||||
<type name="Magento\Store\App\FrontController\Plugin\RequestPreprocessor">
|
||||
<plugin name="magesail_skip_baseurl_redirect" type="MageSail\Magesail\Plugin\Store\SkipBaseUrlRedirectForTunnelPlugin" sortOrder="1"/>
|
||||
</type>
|
||||
<type name="Magento\Store\Model\BaseUrlChecker">
|
||||
<plugin name="magesail_baseurl_checker_tunnel" type="MageSail\Magesail\Plugin\Store\BaseUrlCheckerTunnelPlugin"/>
|
||||
</type>
|
||||
</config>
|
||||
@@ -1,9 +1,10 @@
|
||||
<?xml version="1.0"?>
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
|
||||
<module name="MageSail_Magesail" setup_version="1.0.2">
|
||||
<module name="MageSail_Magesail" setup_version="1.0.3">
|
||||
<sequence>
|
||||
<module name="Magento_Backend"/>
|
||||
<module name="Magento_Config"/>
|
||||
<module name="Magento_Store"/>
|
||||
</sequence>
|
||||
</module>
|
||||
</config>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# NGINX test & reload from PHP (MageSail)
|
||||
|
||||
Magento’s PHP user (e.g. `www-data`) usually **cannot** send `SIGHUP` to the nginx master (`nginx -s reload` → `kill(..., 1)` → **Operation not permitted**). Validation with a **minimal config** (`nginx -t -c …`) can still work because it does not touch the live master.
|
||||
|
||||
## 1. Probe (find what works on your server)
|
||||
|
||||
Run **as the same user as PHP-FPM**:
|
||||
|
||||
```bash
|
||||
sudo -u www-data php /path/to/magento/app/code/MageSail/Magesail/scripts/nginx-reload-probe.php \
|
||||
--map=/etc/nginx/conf.d/global-map.conf \
|
||||
--bin=/usr/sbin/nginx
|
||||
```
|
||||
|
||||
Then try real reload attempts (reloads production nginx):
|
||||
|
||||
```bash
|
||||
sudo -u www-data php .../nginx-reload-probe.php --try-reload --map=/etc/nginx/conf.d/global-map.conf --bin=/usr/sbin/nginx
|
||||
```
|
||||
|
||||
JSON:
|
||||
|
||||
```bash
|
||||
sudo -u www-data php .../nginx-reload-probe.php --json --try-reload
|
||||
```
|
||||
|
||||
## 2. Typical fix: wrapper + sudoers
|
||||
|
||||
1. Install the example script:
|
||||
|
||||
```bash
|
||||
sudo cp app/code/MageSail/Magesail/scripts/magesail-nginx-reload.sh.example /usr/local/bin/magesail-nginx-reload
|
||||
sudo chmod 755 /usr/local/bin/magesail-nginx-reload
|
||||
sudo chown root:root /usr/local/bin/magesail-nginx-reload
|
||||
```
|
||||
|
||||
2. Allow **only** that script for the PHP user (replace `www-data`):
|
||||
|
||||
```text
|
||||
# /etc/sudoers.d/magesail-nginx-reload
|
||||
www-data ALL=(root) NOPASSWD: /usr/local/bin/magesail-nginx-reload
|
||||
```
|
||||
|
||||
3. Wrapper that calls sudo (PHP runs the wrapper; wrapper escalates):
|
||||
|
||||
Create `/usr/local/bin/magesail-nginx-reload-php` owned by root, mode `755`:
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
exec sudo -n /usr/local/bin/magesail-nginx-reload
|
||||
```
|
||||
|
||||
Sudoers:
|
||||
|
||||
```text
|
||||
www-data ALL=(root) NOPASSWD: /usr/local/bin/magesail-nginx-reload
|
||||
```
|
||||
|
||||
PHP user runs `/usr/local/bin/magesail-nginx-reload-php` (no sudo in MageSail config) — **or** set MageSail **Reload wrapper script** to `/usr/local/bin/magesail-nginx-reload-php` if that script only contains the sudo line… Actually simpler: set **Reload wrapper script** in admin to a script that is **executable by www-data** and contains:
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
exec sudo -n /usr/local/bin/magesail-nginx-reload
|
||||
```
|
||||
|
||||
chmod 755, owned by root — www-data can execute it; inside it calls `sudo -n` which is allowed by sudoers for the inner script.
|
||||
|
||||
4. In **Stores → Configuration → Holesail Tunnel → NGINX Configuration**, set **Reload wrapper script** to the absolute path of that wrapper (config path `magesail/nginx/reload_wrapper_script`).
|
||||
|
||||
## 3. Alternative: `systemctl reload nginx`
|
||||
|
||||
If probe shows `systemctl_reload_nginx` works (uncommon for unprivileged users), point the wrapper at `systemctl` or use a sudoers rule for `/bin/systemctl reload nginx`.
|
||||
|
||||
## Security
|
||||
|
||||
- Keep sudoers to **one fixed path**, not generic `nginx` or `sudo` for arbitrary commands.
|
||||
- Wrapper script should be root-owned and not writable by the web user.
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# Example wrapper: reload nginx as root. Install as e.g. /usr/local/bin/magesail-nginx-reload
|
||||
# sudo cp magesail-nginx-reload.sh.example /usr/local/bin/magesail-nginx-reload
|
||||
# sudo chmod 755 /usr/local/bin/magesail-nginx-reload
|
||||
# sudo chown root:root /usr/local/bin/magesail-nginx-reload
|
||||
#
|
||||
# Option A — nginx runs as root master (typical): call nginx directly.
|
||||
exec /usr/sbin/nginx -s reload
|
||||
|
||||
# Option B — if your policy requires systemctl instead, comment A and use:
|
||||
# exec /bin/systemctl reload nginx
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Standalone probe: which nginx test/reload strategy works for THIS user (e.g. PHP-FPM / www-data)?
|
||||
*
|
||||
* Run on the app server as the same user that runs Magento (important):
|
||||
* sudo -u www-data php /path/to/Magesail/scripts/nginx-reload-probe.php
|
||||
*
|
||||
* Or from SSH (shows your user — not the same as web PHP unless you match):
|
||||
* php nginx-reload-probe.php
|
||||
*
|
||||
* Options:
|
||||
* --json Machine-readable output
|
||||
* --map=/path Global map file for minimal nginx -t (default: /etc/nginx/conf.d/global-map.conf)
|
||||
* --bin=/path nginx binary (default: /usr/sbin/nginx)
|
||||
* --try-reload Actually attempt reload for strategies that look safe (default: off for probe-only)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$opts = getopt('', ['json', 'map:', 'bin:', 'try-reload', 'help']);
|
||||
if (isset($opts['help'])) {
|
||||
fwrite(STDERR, "Usage: php nginx-reload-probe.php [--json] [--map=PATH] [--bin=PATH] [--try-reload]\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$asJson = isset($opts['json']);
|
||||
$mapPath = $opts['map'] ?? '/etc/nginx/conf.d/global-map.conf';
|
||||
$nginxBin = $opts['bin'] ?? '/usr/sbin/nginx';
|
||||
$tryReload = isset($opts['try-reload']);
|
||||
|
||||
function runArgv(array $argv): array
|
||||
{
|
||||
$descriptorspec = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
$cmd = implode(' ', array_map('escapeshellarg', $argv));
|
||||
$process = @proc_open($cmd, $descriptorspec, $pipes, null, null);
|
||||
if (!is_resource($process)) {
|
||||
return [127, '', 'proc_open failed for: ' . $cmd];
|
||||
}
|
||||
fclose($pipes[0]);
|
||||
$out = stream_get_contents($pipes[1]);
|
||||
$err = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$code = proc_close($process);
|
||||
$combined = trim(($out !== '' ? rtrim($out) : '') . ($err !== '' ? "\n" . rtrim($err) : ''));
|
||||
return [$code, $combined, $cmd];
|
||||
}
|
||||
|
||||
function minimalConfPath(string $mapInclude, string $pidFile): string
|
||||
{
|
||||
$map = str_replace('\\', '/', $mapInclude);
|
||||
$mapQ = preg_match('/^[a-zA-Z0-9._\/-]+$/', $map) ? $map : '"' . addcslashes($map, '"\\') . '"';
|
||||
$pidQ = preg_match('/^[a-zA-Z0-9._\/-]+$/', $pidFile) ? $pidFile : '"' . addcslashes($pidFile, '"\\') . '"';
|
||||
$body = "error_log stderr;\npid {$pidQ};\nevents {\n worker_connections 1;\n}\nhttp {\n include {$mapQ};\n}\n";
|
||||
$tmp = sys_get_temp_dir() . '/magesail-probe-' . getmypid() . '.conf';
|
||||
file_put_contents($tmp, $body, LOCK_EX);
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
$user = function_exists('posix_getpwuid') && function_exists('posix_geteuid')
|
||||
? (posix_getpwuid(posix_geteuid())['name'] ?? 'unknown')
|
||||
: 'unknown';
|
||||
$uid = function_exists('posix_geteuid') ? posix_geteuid() : -1;
|
||||
|
||||
$results = [
|
||||
'meta' => [
|
||||
'user' => $user,
|
||||
'uid' => $uid,
|
||||
'sapi' => PHP_SAPI,
|
||||
'map_path' => $mapPath,
|
||||
'nginx_bin' => $nginxBin,
|
||||
],
|
||||
'tests' => [],
|
||||
];
|
||||
|
||||
// 1) Minimal nginx -t (same idea as MageSail)
|
||||
// Unique pid path per run: a fixed /tmp/magesail-probe-pid.pid may exist root-owned and break for jetrails.
|
||||
$pidFile = sys_get_temp_dir() . '/magesail-probe-pid-' . getmypid() . '-' . bin2hex(random_bytes(4)) . '.pid';
|
||||
$tmpConf = null;
|
||||
if (is_readable($mapPath)) {
|
||||
$tmpConf = minimalConfPath($mapPath, $pidFile);
|
||||
[$c, $o, $shown] = runArgv([$nginxBin, '-t', '-c', $tmpConf]);
|
||||
$results['tests']['minimal_nginx_t'] = ['exit' => $c, 'ok' => $c === 0, 'output' => $o, 'cmd' => $shown];
|
||||
@unlink($tmpConf);
|
||||
@unlink($pidFile);
|
||||
} else {
|
||||
$results['tests']['minimal_nginx_t'] = [
|
||||
'exit' => -1,
|
||||
'ok' => false,
|
||||
'output' => 'Map file not readable: ' . $mapPath,
|
||||
'cmd' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$reloadStrategies = [
|
||||
'direct_nginx_reload' => [$nginxBin, '-s', 'reload'],
|
||||
'sudo_n_nginx_reload' => ['sudo', '-n', $nginxBin, '-s', 'reload'],
|
||||
'systemctl_reload_nginx' => ['systemctl', 'reload', 'nginx'],
|
||||
'service_nginx_reload' => ['service', 'nginx', 'reload'],
|
||||
];
|
||||
|
||||
foreach ($reloadStrategies as $name => $argv) {
|
||||
if (!$tryReload) {
|
||||
$results['tests'][$name] = [
|
||||
'skipped' => true,
|
||||
'hint' => 'Re-run with --try-reload to execute (may reload production nginx).',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
if ($argv[0] === 'sudo' && !is_executable('/usr/bin/sudo') && !is_executable('/bin/sudo')) {
|
||||
$results['tests'][$name] = ['exit' => -1, 'ok' => false, 'output' => 'sudo not found', 'skipped_binary' => true];
|
||||
continue;
|
||||
}
|
||||
if ($argv[0] === 'systemctl' && !is_executable('/usr/bin/systemctl') && !is_executable('/bin/systemctl')) {
|
||||
$results['tests'][$name] = ['exit' => -1, 'ok' => false, 'output' => 'systemctl not found', 'skipped_binary' => true];
|
||||
continue;
|
||||
}
|
||||
if ($argv[0] === 'service' && !is_executable('/usr/sbin/service') && !is_executable('/usr/bin/service')) {
|
||||
$results['tests'][$name] = ['exit' => -1, 'ok' => false, 'output' => 'service not found', 'skipped_binary' => true];
|
||||
continue;
|
||||
}
|
||||
[$c, $o, $shown] = runArgv($argv);
|
||||
$results['tests'][$name] = ['exit' => $c, 'ok' => $c === 0, 'output' => $o, 'cmd' => $shown];
|
||||
}
|
||||
|
||||
$results['recommendation'] = [];
|
||||
|
||||
if (!empty($results['tests']['minimal_nginx_t']['ok'])) {
|
||||
$results['recommendation'][] = 'minimal nginx -t works — MageSail map syntax validation can succeed.';
|
||||
} else {
|
||||
$results['recommendation'][] = 'Fix minimal_nginx_t first (map path, nginx binary, map file syntax).';
|
||||
}
|
||||
|
||||
if ($tryReload) {
|
||||
$reloadOk = false;
|
||||
foreach (['direct_nginx_reload', 'sudo_n_nginx_reload', 'systemctl_reload_nginx', 'service_nginx_reload'] as $k) {
|
||||
if (!empty($results['tests'][$k]['ok'])) {
|
||||
$results['recommendation'][] = "WORKING RELOAD: {$k} — use a wrapper or MageSail reload_wrapper_script (see README-NGINX-RELOAD.md).";
|
||||
$reloadOk = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$reloadOk) {
|
||||
$results['recommendation'][] = 'Reload: none succeeded as user ' . $user . ' (typical: master is root → kill EPERM; sudo/systemctl need a TTY or NOPASSWD). '
|
||||
. 'Use a root-owned helper script + /etc/sudoers.d/ allowing ' . $user . ' NOPASSWD for that path only, point MageSail “Reload wrapper script” at a small script that runs sudo -n on the helper. '
|
||||
. 'See Magesail/scripts/README-NGINX-RELOAD.md and magesail-nginx-reload.sh.example.';
|
||||
}
|
||||
} else {
|
||||
$results['recommendation'][] = 'Run as your PHP user, e.g.: sudo -u ' . $user . ' php ' . __FILE__ . ' --try-reload --map=' . escapeshellarg($mapPath) . ' --bin=' . escapeshellarg($nginxBin);
|
||||
$results['recommendation'][] = 'If direct_nginx_reload fails (kill EPERM), use sudoers NOPASSWD for a fixed wrapper script (README-NGINX-RELOAD.md).';
|
||||
}
|
||||
|
||||
if ($asJson) {
|
||||
echo json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
} else {
|
||||
echo "MageSail nginx probe\n";
|
||||
echo "====================\n";
|
||||
echo "User: {$user} (uid {$uid}) SAPI: " . PHP_SAPI . "\n\n";
|
||||
foreach ($results['tests'] as $name => $t) {
|
||||
echo "[{$name}]\n";
|
||||
if (!empty($t['skipped'])) {
|
||||
echo " skipped: {$t['hint']}\n\n";
|
||||
continue;
|
||||
}
|
||||
$ok = $t['ok'] ?? ($t['exit'] === 0);
|
||||
echo ' ok: ' . ($ok ? 'YES' : 'NO') . ' exit: ' . ($t['exit'] ?? 'n/a') . "\n";
|
||||
if (!empty($t['output'])) {
|
||||
echo ' output: ' . str_replace("\n", "\n ", $t['output']) . "\n";
|
||||
}
|
||||
if (!empty($t['cmd'])) {
|
||||
echo " cmd: {$t['cmd']}\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
echo "Recommendation\n";
|
||||
echo "--------------\n";
|
||||
foreach ($results['recommendation'] as $line) {
|
||||
echo "- {$line}\n";
|
||||
}
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test script: emulate the tunnel proxy and hit Magento to verify redirect behavior.
|
||||
* Usage: node test-proxy-redirect.js [listenPort] [backendPort] [storeCode] [storeHost]
|
||||
* Defaults: listenPort=9999, backendPort=80, storeCode=default, storeHost=s1.x64.world
|
||||
*
|
||||
* Then: curl -IL http://127.0.0.1:9999/
|
||||
* If you get 301, the redirect is still happening. If 200, the fix works.
|
||||
*/
|
||||
const http = require('http');
|
||||
|
||||
const listenPort = parseInt(process.argv[2], 10) || 9999;
|
||||
const backendPort = parseInt(process.argv[3], 10) || 80;
|
||||
const storeCode = process.argv[4] || 'default';
|
||||
const storeHost = process.argv[5] || 's1.x64.world';
|
||||
|
||||
console.log('Test proxy: listen', listenPort, '-> backend 127.0.0.1:' + backendPort);
|
||||
console.log('Headers sent to backend: Host=' + storeHost + ', X-MageSail-Store-Code=' + storeCode + ', X-Forwarded-Proto=https');
|
||||
console.log('curl -IL http://127.0.0.1:' + listenPort + '/');
|
||||
console.log('');
|
||||
|
||||
const server = http.createServer((clientReq, clientRes) => {
|
||||
let path = clientReq.url;
|
||||
const sep = path.includes('?') ? '&' : '?';
|
||||
path = path + sep + 'magesail_store=' + encodeURIComponent(storeCode);
|
||||
const opts = {
|
||||
hostname: '127.0.0.1',
|
||||
port: backendPort,
|
||||
path: path,
|
||||
method: clientReq.method,
|
||||
headers: {
|
||||
...clientReq.headers,
|
||||
host: storeHost,
|
||||
'x-magesail-store-code': storeCode,
|
||||
'x-magesail-store-type': 'store',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
};
|
||||
console.log('[proxy]', clientReq.method, clientReq.url, '->', 'http://127.0.0.1:' + backendPort + path);
|
||||
const backendReq = http.request(opts, (backendRes) => {
|
||||
console.log('[backend]', backendRes.statusCode, backendRes.headers.location || '');
|
||||
clientRes.writeHead(backendRes.statusCode, backendRes.headers);
|
||||
backendRes.pipe(clientRes, { end: true });
|
||||
});
|
||||
backendReq.on('error', (err) => {
|
||||
console.error('[backend error]', err.message);
|
||||
clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
|
||||
clientRes.end('Bad Gateway: ' + err.message);
|
||||
});
|
||||
clientReq.pipe(backendReq, { end: true });
|
||||
});
|
||||
|
||||
server.listen(listenPort, '127.0.0.1', () => {
|
||||
console.log('Listening on http://127.0.0.1:' + listenPort + '/');
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0"?>
|
||||
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
|
||||
<head>
|
||||
<css src="MageSail_Magesail::css/magesail-tunnel.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<referenceContainer name="content">
|
||||
<block class="MageSail\Magesail\Block\Adminhtml\Nginx\Map" name="magesail.nginx.map" template="MageSail_Magesail::nginx/map.phtml"/>
|
||||
</referenceContainer>
|
||||
</body>
|
||||
</page>
|
||||
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
/** @var \MageSail\Magesail\Block\Adminhtml\Nginx\Map $block */
|
||||
$mapPath = $block->getMapFilePath();
|
||||
$mapContent = $block->getMapContent();
|
||||
$mapUrl = $block->getMapUrl();
|
||||
$formKey = $block->escapeHtml($block->getFormKey());
|
||||
$configUrl = $block->escapeUrl($block->getUrl('adminhtml/system_config/edit/section/magesail'));
|
||||
?>
|
||||
<div class="magesail-nginx-map">
|
||||
<div class="page-title-wrapper">
|
||||
<h1 class="page-title"><?= $block->escapeHtml(__('NGINX Global Map Editor')) ?></h1>
|
||||
</div>
|
||||
|
||||
<div id="magesail-flash" class="message" style="display:none"></div>
|
||||
|
||||
<?php if ($mapPath === null): ?>
|
||||
<div class="message message-warning warning">
|
||||
<span><?= $block->escapeHtml(__('NGINX global map path is not configured. Please set it in Stores → Configuration → Holesail Tunnel → NGINX Configuration.')) ?></span>
|
||||
<p><a href="<?= $configUrl ?>" class="action-secondary"><?= $block->escapeHtml(__('Go to Configuration')) ?></a></p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="admin__page-section">
|
||||
<div class="admin__page-section-title">
|
||||
<span class="title"><?= $block->escapeHtml(__('Map file: %1', $mapPath)) ?></span>
|
||||
</div>
|
||||
<div class="admin__page-section-content">
|
||||
<p class="admin__field-note">
|
||||
<?= $block->escapeHtml(__('This file is automatically updated when tunnels are created or stopped. Edit map blocks below or use raw editor.')) ?>
|
||||
</p>
|
||||
<div class="magesail-actions" style="margin-bottom: 1rem;">
|
||||
<button type="button" class="action-secondary" id="magesail-btn-parse">
|
||||
<span><?= $block->escapeHtml(__('Parse & Load Blocks')) ?></span>
|
||||
</button>
|
||||
<button type="button" class="action-secondary" id="magesail-btn-validate">
|
||||
<span><?= $block->escapeHtml(__('Validate NGINX Config')) ?></span>
|
||||
</button>
|
||||
<button type="button" class="action-secondary" id="magesail-btn-reload">
|
||||
<span><?= $block->escapeHtml(__('Validate & Reload NGINX')) ?></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="magesail-map-blocks" style="display:none;">
|
||||
<!-- Blocks will be rendered here -->
|
||||
</div>
|
||||
|
||||
<div id="magesail-map-raw-editor" style="display:none;">
|
||||
<div class="admin__field">
|
||||
<label class="admin__field-label" for="magesail-map-content">
|
||||
<span><?= $block->escapeHtml(__('Raw map file content')) ?></span>
|
||||
</label>
|
||||
<div class="admin__field-control">
|
||||
<textarea id="magesail-map-content" name="content" class="admin__control-textarea" rows="20" style="font-family: monospace; font-size: 12px;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="magesail-actions">
|
||||
<button type="button" class="action-primary" id="magesail-btn-save-raw">
|
||||
<span><?= $block->escapeHtml(__('Save Raw Content')) ?></span>
|
||||
</button>
|
||||
<button type="button" class="action-secondary" id="magesail-btn-switch-gui">
|
||||
<span><?= $block->escapeHtml(__('Switch to GUI Editor')) ?></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="magesail-map-loading" class="message message-info info">
|
||||
<span><?= $block->escapeHtml(__('Click "Parse & Load Blocks" to load the map file.')) ?></span>
|
||||
</div>
|
||||
|
||||
<div id="magesail-diagnostics" style="margin-top: 1rem; padding: 1rem; background: #f0f0f0; border: 1px solid #ddd;">
|
||||
<strong><?= $block->escapeHtml(__('File Diagnostics:')) ?></strong>
|
||||
<div id="magesail-diagnostics-content">
|
||||
<p><?= $block->escapeHtml(__('Path: %1', $mapPath)) ?></p>
|
||||
<p><?= $block->escapeHtml(__('Click "Parse & Load Blocks" to check file status.')) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
require(['jquery', 'domReady!'], function ($) {
|
||||
var mapUrl = <?= json_encode($mapUrl) ?>;
|
||||
var formKey = <?= json_encode($formKey) ?>;
|
||||
var parsedData = null;
|
||||
|
||||
function flash(msg, isError) {
|
||||
var el = $('#magesail-flash');
|
||||
el.removeClass('message-success success message-error error message-warning warning')
|
||||
.addClass(isError ? 'message-error error' : 'message-success success')
|
||||
.html('<span>' + $('<div/>').text(msg).html() + '</span>')
|
||||
.show();
|
||||
if (!isError) {
|
||||
setTimeout(function () { el.fadeOut(); }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBlocks(blocks) {
|
||||
var html = '<div class="magesail-map-blocks-container">';
|
||||
blocks.forEach(function(block, blockIdx) {
|
||||
html += '<div class="admin__fieldset magesail-map-block" data-block-index="' + blockIdx + '">';
|
||||
html += '<div class="admin__fieldset-header">';
|
||||
html += '<strong>map $' + $('<div/>').text(block.source).html() + ' $' + $('<div/>').text(block.target).html() + ' {</strong>';
|
||||
html += '<button type="button" class="action-secondary magesail-add-entry" data-block="' + blockIdx + '">Add Entry</button>';
|
||||
html += '</div>';
|
||||
html += '<table class="admin__table-primary magesail-map-entries">';
|
||||
html += '<thead><tr><th>Hostname</th><th>Value</th><th>Comment</th><th>Actions</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
block.entries.forEach(function(entry, entryIdx) {
|
||||
html += '<tr data-entry-index="' + entryIdx + '">';
|
||||
html += '<td><input type="text" class="admin__control-text magesail-hostname" value="' + $('<div/>').text(entry.hostname).html() + '" /></td>';
|
||||
html += '<td><input type="text" class="admin__control-text magesail-value" value="' + $('<div/>').text(entry.value).html() + '" /></td>';
|
||||
html += '<td><input type="text" class="admin__control-text magesail-comment" value="' + $('<div/>').text(entry.comment || '').html() + '" /></td>';
|
||||
html += '<td><button type="button" class="action-delete magesail-delete-entry">Delete</button></td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
html += '<div class="magesail-block-footer">}</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
html += '<div class="magesail-actions" style="margin-top: 1rem;">';
|
||||
html += '<button type="button" class="action-primary" id="magesail-btn-save-blocks">';
|
||||
html += '<span>Save Blocks & Validate</span>';
|
||||
html += '</button>';
|
||||
html += '<button type="button" class="action-secondary" id="magesail-btn-switch-raw">';
|
||||
html += '<span>Switch to Raw Editor</span>';
|
||||
html += '</button>';
|
||||
html += '</div>';
|
||||
$('#magesail-map-blocks').html(html).show();
|
||||
$('#magesail-map-loading').hide();
|
||||
$('#magesail-map-raw-editor').hide();
|
||||
|
||||
$('.magesail-add-entry').on('click', function() {
|
||||
var blockIdx = $(this).data('block');
|
||||
var tbody = $(this).closest('.magesail-map-block').find('tbody');
|
||||
var row = '<tr data-entry-index="new">';
|
||||
row += '<td><input type="text" class="admin__control-text magesail-hostname" value="" /></td>';
|
||||
row += '<td><input type="text" class="admin__control-text magesail-value" value="" /></td>';
|
||||
row += '<td><input type="text" class="admin__control-text magesail-comment" value="" /></td>';
|
||||
row += '<td><button type="button" class="action-delete magesail-delete-entry">Delete</button></td>';
|
||||
row += '</tr>';
|
||||
tbody.append(row);
|
||||
});
|
||||
|
||||
$('.magesail-delete-entry').on('click', function() {
|
||||
$(this).closest('tr').remove();
|
||||
});
|
||||
}
|
||||
|
||||
function updateDiagnostics() {
|
||||
$.ajax({
|
||||
url: mapUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
data: { form_key: formKey, action: 'diagnostics', ajax: '1' }
|
||||
}).done(function (res) {
|
||||
if (res.success && res.diagnostics) {
|
||||
var d = res.diagnostics;
|
||||
var html = '<p><strong>Path:</strong> ' + $('<div/>').text(d.path || 'Not configured').html() + '</p>';
|
||||
html += '<p><strong>Exists:</strong> ' + (d.exists ? 'Yes' : 'No') + '</p>';
|
||||
html += '<p><strong>Readable:</strong> ' + (d.readable ? 'Yes' : 'No') + '</p>';
|
||||
html += '<p><strong>Writable:</strong> ' + (d.writable ? 'Yes' : 'No') + '</p>';
|
||||
if (d.error) {
|
||||
html += '<p class="message message-error"><strong>Issue:</strong> ' + $('<div/>').text(d.error).html() + '</p>';
|
||||
}
|
||||
$('#magesail-diagnostics-content').html(html);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#magesail-btn-parse').on('click', function() {
|
||||
updateDiagnostics();
|
||||
$.ajax({
|
||||
url: mapUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
data: { form_key: formKey, action: 'parse', ajax: '1' }
|
||||
}).done(function (res) {
|
||||
if (res.success && res.parsed) {
|
||||
parsedData = res.parsed;
|
||||
renderBlocks(res.parsed.blocks);
|
||||
flash('Map file parsed successfully.', false);
|
||||
} else {
|
||||
flash(res.message || 'Failed to parse map file', true);
|
||||
}
|
||||
}).fail(function (xhr) {
|
||||
var m = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : '';
|
||||
if (!m) {
|
||||
m = xhr.status ? ('HTTP ' + xhr.status + (xhr.statusText ? ' ' + xhr.statusText : '')) : 'Request failed';
|
||||
if (xhr.responseText && xhr.responseText.length && xhr.responseText.charAt(0) !== '<') {
|
||||
m += ': ' + xhr.responseText.substring(0, 300);
|
||||
}
|
||||
}
|
||||
flash(m, true);
|
||||
});
|
||||
});
|
||||
|
||||
updateDiagnostics();
|
||||
|
||||
$(document).on('click', '#magesail-btn-save-blocks', function() {
|
||||
var blocks = [];
|
||||
$('.magesail-map-block').each(function() {
|
||||
var block = $(this);
|
||||
var headerText = block.find('.admin__fieldset-header strong').text();
|
||||
var matches = headerText.match(/\$(\w+)\s+\$(\w+)/);
|
||||
if (!matches || matches.length < 3) {
|
||||
flash('Invalid map block header format', true);
|
||||
return false;
|
||||
}
|
||||
var source = matches[1];
|
||||
var target = matches[2];
|
||||
var entries = [];
|
||||
block.find('tbody tr').each(function() {
|
||||
var hostname = $(this).find('.magesail-hostname').val().trim();
|
||||
var value = $(this).find('.magesail-value').val().trim();
|
||||
var comment = $(this).find('.magesail-comment').val().trim();
|
||||
if (hostname && value) {
|
||||
entries.push({
|
||||
hostname: hostname,
|
||||
value: value,
|
||||
comment: comment || null
|
||||
});
|
||||
}
|
||||
});
|
||||
blocks.push({
|
||||
source: source,
|
||||
target: target,
|
||||
entries: entries
|
||||
});
|
||||
});
|
||||
$.ajax({
|
||||
url: mapUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
data: {
|
||||
form_key: formKey,
|
||||
action: 'save_blocks',
|
||||
ajax: '1',
|
||||
blocks: JSON.stringify(blocks)
|
||||
}
|
||||
}).done(function (res) {
|
||||
if (res.success) {
|
||||
flash(res.message || 'Blocks saved successfully.', false);
|
||||
if (res.validation && !res.validation.valid) {
|
||||
flash('Warning: ' + (res.validation.error || 'NGINX validation failed'), true);
|
||||
}
|
||||
} else {
|
||||
flash(res.message || 'Failed to save blocks', true);
|
||||
}
|
||||
}).fail(function (xhr) {
|
||||
var m = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : '';
|
||||
if (!m) {
|
||||
m = xhr.status ? ('HTTP ' + xhr.status + (xhr.statusText ? ' ' + xhr.statusText : '')) : 'Request failed';
|
||||
if (xhr.responseText && xhr.responseText.length && xhr.responseText.charAt(0) !== '<') {
|
||||
m += ': ' + xhr.responseText.substring(0, 300);
|
||||
}
|
||||
}
|
||||
flash(m, true);
|
||||
});
|
||||
});
|
||||
|
||||
$('#magesail-btn-switch-raw').on('click', function() {
|
||||
$('#magesail-map-blocks').hide();
|
||||
$('#magesail-map-raw-editor').show();
|
||||
if ($('#magesail-map-content').val() === '') {
|
||||
$.ajax({
|
||||
url: mapUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
data: { form_key: formKey, action: 'read', ajax: '1' }
|
||||
}).done(function (res) {
|
||||
if (res.success && res.content) {
|
||||
$('#magesail-map-content').val(res.content);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('#magesail-btn-switch-gui').on('click', function() {
|
||||
$('#magesail-map-raw-editor').hide();
|
||||
$('#magesail-map-loading').show();
|
||||
$('#magesail-btn-parse').trigger('click');
|
||||
});
|
||||
|
||||
$('#magesail-btn-save-raw').on('click', function() {
|
||||
var content = $('#magesail-map-content').val();
|
||||
$.ajax({
|
||||
url: mapUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
data: { form_key: formKey, action: 'write', ajax: '1', content: content }
|
||||
}).done(function (res) {
|
||||
if (res.success) {
|
||||
flash(res.message || 'Raw content saved.', false);
|
||||
if (res.validation && !res.validation.valid) {
|
||||
flash('Warning: ' + (res.validation.error || 'NGINX validation failed'), true);
|
||||
}
|
||||
} else {
|
||||
flash(res.message || 'Failed to save', true);
|
||||
}
|
||||
}).fail(function (xhr) {
|
||||
var m = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : '';
|
||||
if (!m) {
|
||||
m = xhr.status ? ('HTTP ' + xhr.status + (xhr.statusText ? ' ' + xhr.statusText : '')) : 'Request failed';
|
||||
if (xhr.responseText && xhr.responseText.length && xhr.responseText.charAt(0) !== '<') {
|
||||
m += ': ' + xhr.responseText.substring(0, 300);
|
||||
}
|
||||
}
|
||||
flash(m, true);
|
||||
});
|
||||
});
|
||||
|
||||
function ajaxAction(action, data, successMsg) {
|
||||
$.ajax({
|
||||
url: mapUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
data: $.extend({ form_key: formKey, action: action, ajax: '1' }, data || {})
|
||||
}).done(function (res) {
|
||||
if (res.success) {
|
||||
flash(successMsg || res.message || 'Operation completed successfully.', false);
|
||||
if (res.validation && !res.validation.valid) {
|
||||
flash('Warning: ' + (res.validation.error || 'NGINX validation failed'), true);
|
||||
}
|
||||
} else {
|
||||
flash(res.message || 'Operation failed', true);
|
||||
}
|
||||
}).fail(function (xhr) {
|
||||
var m = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : '';
|
||||
if (!m) {
|
||||
m = xhr.status ? ('HTTP ' + xhr.status + (xhr.statusText ? ' ' + xhr.statusText : '')) : 'Request failed';
|
||||
if (xhr.responseText && xhr.responseText.length && xhr.responseText.charAt(0) !== '<') {
|
||||
m += ': ' + xhr.responseText.substring(0, 300);
|
||||
}
|
||||
}
|
||||
flash(m, true);
|
||||
});
|
||||
}
|
||||
|
||||
$('#magesail-btn-validate').on('click', function() {
|
||||
ajaxAction('validate', null, 'NGINX configuration validated.');
|
||||
});
|
||||
|
||||
$('#magesail-btn-reload').on('click', function() {
|
||||
ajaxAction('reload', null, 'NGINX reloaded.');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.magesail-map-block {
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid #e3e3e3;
|
||||
padding: 1rem;
|
||||
background: #f8f8f8;
|
||||
}
|
||||
.magesail-map-block .admin__fieldset-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.magesail-map-entries {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.magesail-map-entries th {
|
||||
text-align: left;
|
||||
padding: 0.5rem;
|
||||
background: #fff;
|
||||
}
|
||||
.magesail-map-entries td {
|
||||
padding: 0.5rem;
|
||||
background: #fff;
|
||||
}
|
||||
.magesail-map-entries input {
|
||||
width: 100%;
|
||||
}
|
||||
.magesail-block-footer {
|
||||
margin-top: 0.5rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
/** @var \MageSail\Magesail\Block\Adminhtml\Tunnel $block */
|
||||
$status = $block->getTunnelStatus();
|
||||
$tunnelStoreState = $block->getTunnelStoreState();
|
||||
$defaultStoreCode = $block->getTunnelStoreCodeConstant();
|
||||
$formKey = $block->escapeHtml($block->getFormKey());
|
||||
$statusUrl = $block->getStatusUrl();
|
||||
$indexUrl = $block->getIndexUrl();
|
||||
@@ -8,6 +10,14 @@ $configUrl = $block->escapeUrl($block->getConfigUrl());
|
||||
$logPath = $block->escapeHtml($block->getLogFilePath());
|
||||
$logLines = $block->getRecentLogLines();
|
||||
$running = $status['running'];
|
||||
$prefillUnsecure = $tunnelStoreState['unsecure_base_url'] ?? '';
|
||||
$prefillSecure = $tunnelStoreState['secure_base_url'] ?? '';
|
||||
$prefillUseSecure = !empty($tunnelStoreState['use_secure_urls']);
|
||||
$nginxPendingReload = $running && !empty($tunnelStoreState['nginx_pending_reload']);
|
||||
$nginxReloadHint = (string) __(
|
||||
'Reload NGINX on this server so the updated global map is active (for example: sudo systemctl reload nginx). ' .
|
||||
'Or set Reload wrapper script in Admin under Configuration for the Holesail Tunnel NGINX settings so Magento can reload automatically next time.'
|
||||
);
|
||||
?>
|
||||
<div class="magesail-tunnel"
|
||||
id="magesail-root"
|
||||
@@ -36,6 +46,44 @@ $running = $status['running'];
|
||||
<div class="message message-warning warning">
|
||||
<span><?= $block->escapeHtml(__('No active tunnel. Start one to expose your dev site.')) ?></span>
|
||||
</div>
|
||||
<div class="magesail-tunnel-url-fields admin__fieldset">
|
||||
<p class="admin__field-note" style="margin-bottom:1rem">
|
||||
<?= $block->escapeHtml(__('Use the same hostname you configure as a virtual host in holesail-browser (e.g. http://dev.myshop.local/). A store view with code %1 will be created and removed when you stop the tunnel.', $defaultStoreCode)) ?>
|
||||
</p>
|
||||
<p class="message message-notice notice" style="margin-bottom:1rem">
|
||||
<?= $block->escapeHtml(__('Removing the tunnel deletes that store view. If you used it for real orders, back up first.')) ?>
|
||||
</p>
|
||||
<div class="admin__field _required">
|
||||
<label class="admin__field-label" for="magesail-tunnel-base-url">
|
||||
<span><?= $block->escapeHtml(__('Tunnel base URL (unsecure)')) ?></span>
|
||||
</label>
|
||||
<div class="admin__field-control">
|
||||
<input type="text" id="magesail-tunnel-base-url" class="admin__control-text"
|
||||
value="<?= $block->escapeHtmlAttr($prefillUnsecure) ?>"
|
||||
placeholder="http://dev.myshop.local/"
|
||||
autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin__field">
|
||||
<label class="admin__field-label" for="magesail-tunnel-secure-base-url">
|
||||
<span><?= $block->escapeHtml(__('Tunnel base URL (secure, optional)')) ?></span>
|
||||
</label>
|
||||
<div class="admin__field-control">
|
||||
<input type="text" id="magesail-tunnel-secure-base-url" class="admin__control-text"
|
||||
value="<?= $block->escapeHtmlAttr($prefillSecure) ?>"
|
||||
placeholder="https://dev.myshop.local/"
|
||||
autocomplete="off"/>
|
||||
<p class="admin__field-note"><?= $block->escapeHtml(__('Leave empty to mirror unsecure with https if unsecure is http, otherwise same as unsecure.')) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin__field admin__field-option">
|
||||
<input type="checkbox" id="magesail-tunnel-use-secure" class="admin__control-checkbox"
|
||||
<?= $prefillUseSecure ? 'checked="checked"' : '' ?> />
|
||||
<label for="magesail-tunnel-use-secure" class="admin__field-label">
|
||||
<span><?= $block->escapeHtml(__('Use secure URLs on storefront and in admin')) ?></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="magesail-actions">
|
||||
<button type="button" class="action-primary" id="magesail-btn-start">
|
||||
<span><?= $block->escapeHtml(__('Start Tunnel')) ?></span>
|
||||
@@ -51,6 +99,17 @@ $running = $status['running'];
|
||||
}
|
||||
?></span>
|
||||
</div>
|
||||
<?php if ($nginxPendingReload): ?>
|
||||
<div class="message message-warning warning" id="magesail-nginx-reload-banner" role="alert">
|
||||
<span><?= $block->escapeHtml($nginxReloadHint) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$displayStoreCode = $tunnelStoreState['store_code'] ?? $defaultStoreCode;
|
||||
?>
|
||||
<p class="admin__field-note" id="magesail-store-code-hint" style="margin-bottom:1rem">
|
||||
<?= $block->escapeHtml(__('Tunnel store view code: %1 — configure holesail-browser to send header X-MageSail-Store-Code: %2 (or query %3 / Magento %4).', $displayStoreCode, $displayStoreCode, 'magesail_store=' . $displayStoreCode, '___store=' . $displayStoreCode)) ?>
|
||||
</p>
|
||||
<div class="magesail-key-wrap" id="magesail-key-section">
|
||||
<strong><?= $block->escapeHtml(__('Share key')) ?></strong>
|
||||
<code class="magesail-key" id="magesail-key-value"><?= $block->escapeHtml($status['key'] ?: '') ?></code>
|
||||
@@ -115,6 +174,15 @@ require(['jquery', 'domReady!'], function ($) {
|
||||
}
|
||||
}
|
||||
|
||||
function flashWarning(msg) {
|
||||
var el = $('#magesail-flash');
|
||||
el.removeClass('message-success success message-error error message-warning warning')
|
||||
.addClass('message-warning warning')
|
||||
.html('<span>' + $('<div/>').text(msg).html() + '</span>')
|
||||
.show();
|
||||
setTimeout(function () { el.fadeOut(); }, 20000);
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
if (!text) return false;
|
||||
try {
|
||||
@@ -225,16 +293,37 @@ require(['jquery', 'domReady!'], function ($) {
|
||||
});
|
||||
|
||||
$('#magesail-btn-start').on('click', function () {
|
||||
var baseUrl = ($('#magesail-tunnel-base-url').val() || '').trim();
|
||||
if (!baseUrl) {
|
||||
flash(<?= json_encode((string) __('Enter the tunnel base URL (must match holesail-browser vhost).')) ?>, true);
|
||||
return;
|
||||
}
|
||||
var btn = $(this).prop('disabled', true);
|
||||
$.ajax({
|
||||
url: indexUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
data: { form_key: formKey, action: 'start', ajax: '1' }
|
||||
data: {
|
||||
form_key: formKey,
|
||||
action: 'start',
|
||||
ajax: '1',
|
||||
tunnel_base_url: baseUrl,
|
||||
tunnel_secure_base_url: ($('#magesail-tunnel-secure-base-url').val() || '').trim(),
|
||||
tunnel_use_secure_urls: $('#magesail-tunnel-use-secure').is(':checked') ? '1' : '0'
|
||||
}
|
||||
}).done(function (res) {
|
||||
if (res.success && res.status) {
|
||||
flash(res.message || '', false);
|
||||
if (res.nginx_reload_required && res.nginx_reload_message) {
|
||||
flashWarning(res.nginx_reload_message);
|
||||
if (!$('#magesail-nginx-reload-banner').length) {
|
||||
$('#magesail-status-banner').after(
|
||||
'<div class="message message-warning warning" id="magesail-nginx-reload-banner" role="alert"><span>'
|
||||
+ $('<div/>').text(res.nginx_reload_message).html() + '</span></div>'
|
||||
);
|
||||
}
|
||||
}
|
||||
showRunning(res.status);
|
||||
} else {
|
||||
flash(res.message || 'Start failed', true);
|
||||
|
||||
Reference in New Issue
Block a user