Read X-Forwarded-Host (and HTTP_X_FORWARDED_HOST) so tunnel requests resolve
CI / php (push) Successful in 1m39s
CI / php (push) Successful in 1m39s
the correct store behind a proxy. TunnelStoreProvisioner now receives ResourceConnection and copies default web CMS pages/blocks and theme-related setup for new tunnel stores. Tighten system.xml (remove invalid default), crontab/config defaults, and update tests with a ResourceConnection stub for PHPUnit.
This commit is contained in:
@@ -17,20 +17,68 @@ class TunnelHostnameMatcher
|
||||
}
|
||||
|
||||
/**
|
||||
* When HTTP_HOST matches unsecure or secure tunnel base URL host, return the persisted store code.
|
||||
* When a request host header matches unsecure or secure tunnel base URL host, return the persisted store code.
|
||||
*
|
||||
* Tries HTTP_HOST first, then proxy headers (X-Forwarded-Host / HTTP_X_FORWARDED_HOST), so tunnels still
|
||||
* resolve when PHP sees an internal host (e.g. 127.0.0.1) but the client Host was dev.example.com.
|
||||
*/
|
||||
public function getStoreCodeIfRequestHostMatchesState(Http $request): ?string
|
||||
{
|
||||
$httpHost = $request->getServer('HTTP_HOST');
|
||||
if ($httpHost === null || $httpHost === '') {
|
||||
return null;
|
||||
}
|
||||
$match = $this->tunnelRegistry->findByRequestHost($httpHost);
|
||||
if ($match === null) {
|
||||
return null;
|
||||
}
|
||||
foreach ($this->collectRequestHostCandidates($request) as $host) {
|
||||
$match = $this->tunnelRegistry->findByRequestHost($host);
|
||||
if ($match !== null) {
|
||||
return (string) ($match['store_code'] ?? '');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectRequestHostCandidates(Http $request): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
foreach ($this->rawHostCandidates($request) as $raw) {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
continue;
|
||||
}
|
||||
$hostKey = strtolower($raw);
|
||||
if (isset($seen[$hostKey])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$hostKey] = true;
|
||||
$out[] = $raw;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function rawHostCandidates(Http $request): array
|
||||
{
|
||||
$candidates = [];
|
||||
$httpHost = $request->getServer('HTTP_HOST');
|
||||
if ($httpHost !== null && $httpHost !== '') {
|
||||
$candidates[] = $httpHost;
|
||||
}
|
||||
$xForwardedHost = $request->getServer('HTTP_X_FORWARDED_HOST');
|
||||
if ($xForwardedHost !== null && $xForwardedHost !== '') {
|
||||
foreach (explode(',', $xForwardedHost) as $part) {
|
||||
$candidates[] = trim($part);
|
||||
}
|
||||
}
|
||||
$header = $request->getHeader('X-Forwarded-Host');
|
||||
if ($header !== false && $header !== null && trim((string) $header) !== '') {
|
||||
foreach (explode(',', (string) $header) as $part) {
|
||||
$candidates[] = trim($part);
|
||||
}
|
||||
}
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
public static function hostFromHttpHost(?string $httpHost): ?string
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ 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\App\ResourceConnection;
|
||||
use Magento\Framework\Exception\LocalizedException;
|
||||
use Magento\Framework\Exception\NoSuchEntityException;
|
||||
use Magento\Store\Api\StoreRepositoryInterface;
|
||||
@@ -30,6 +31,25 @@ class TunnelStoreProvisioner
|
||||
private const PATH_USE_IN_FRONTEND = 'web/secure/use_in_frontend';
|
||||
private const PATH_USE_IN_ADMIN = 'web/secure/use_in_adminhtml';
|
||||
|
||||
/** @see \Magento\Cms\Helper\Page */
|
||||
private const PATH_CMS_HOME = 'web/default/cms_home_page';
|
||||
|
||||
private const PATH_CMS_NO_ROUTE = 'web/default/cms_no_route';
|
||||
|
||||
private const PATH_CMS_NO_COOKIES = 'web/default/cms_no_cookies';
|
||||
|
||||
private const PATH_WEB_FRONT = 'web/default/front';
|
||||
|
||||
private const PATH_THEME_ID = 'design/theme/theme_id';
|
||||
|
||||
/** Paths copied from the website default store view so tunnel storefronts match the main demo/home (CMS + widgets). */
|
||||
private const WEB_DEFAULT_PATHS = [
|
||||
self::PATH_CMS_HOME,
|
||||
self::PATH_CMS_NO_ROUTE,
|
||||
self::PATH_CMS_NO_COOKIES,
|
||||
self::PATH_WEB_FRONT,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly StoreManagerInterface $storeManager,
|
||||
private readonly StoreFactory $storeFactory,
|
||||
@@ -42,7 +62,8 @@ class TunnelStoreProvisioner
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly NginxGlobalMapManager $nginxMapManager,
|
||||
private readonly ScopeConfigInterface $scopeConfig,
|
||||
private readonly TunnelProcessPaths $tunnelProcessPaths
|
||||
private readonly TunnelProcessPaths $tunnelProcessPaths,
|
||||
private readonly ResourceConnection $resource
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -83,6 +104,7 @@ class TunnelStoreProvisioner
|
||||
$storeId = (int) $store->getId();
|
||||
|
||||
$this->writeStoreBaseConfig($storeId, $unsecureBase, $secureBase, $useSecureUrls);
|
||||
$this->copyStorefrontDefaultsFromWebsiteDefault($websiteId, $storeId);
|
||||
|
||||
$nginxReloaded = $this->applyNginxMap($unsecureBase, $storeCode);
|
||||
|
||||
@@ -381,6 +403,46 @@ class TunnelStoreProvisioner
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Align tunnel store view with the website's default store: CMS home/front + theme (Luma widgets) + cms_*_store rows.
|
||||
*/
|
||||
private function copyStorefrontDefaultsFromWebsiteDefault(int $websiteId, int $newStoreId): void
|
||||
{
|
||||
try {
|
||||
$website = $this->storeManager->getWebsite($websiteId);
|
||||
$defaultStore = $website->getDefaultStore();
|
||||
if ($defaultStore === null || (int) $defaultStore->getId() === $newStoreId) {
|
||||
return;
|
||||
}
|
||||
$fromStoreId = (int) $defaultStore->getId();
|
||||
$fromStoreCode = $defaultStore->getCode();
|
||||
foreach (self::WEB_DEFAULT_PATHS as $path) {
|
||||
$value = $this->scopeConfig->getValue($path, ScopeInterface::SCOPE_STORES, $fromStoreCode);
|
||||
if ($value !== null && $value !== '') {
|
||||
$this->configWriter->save($path, (string) $value, ScopeInterface::SCOPE_STORES, $newStoreId);
|
||||
}
|
||||
}
|
||||
$theme = $this->scopeConfig->getValue(self::PATH_THEME_ID, ScopeInterface::SCOPE_STORES, $fromStoreCode);
|
||||
if ($theme !== null && $theme !== '') {
|
||||
$this->configWriter->save(self::PATH_THEME_ID, (string) $theme, ScopeInterface::SCOPE_STORES, $newStoreId);
|
||||
}
|
||||
$this->mirrorCmsEntityStoreAssignments($fromStoreId, $newStoreId);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('MageSail: could not copy storefront defaults to tunnel store: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function mirrorCmsEntityStoreAssignments(int $fromStoreId, int $newStoreId): void
|
||||
{
|
||||
$conn = $this->resource->getConnection();
|
||||
foreach (['cms_page_store' => 'page_id', 'cms_block_store' => 'block_id'] as $table => $idColumn) {
|
||||
$t = $this->resource->getTableName($table);
|
||||
$sql = "INSERT IGNORE INTO {$t} ({$idColumn}, store_id) "
|
||||
. "SELECT DISTINCT {$idColumn}, :new_store FROM {$t} WHERE store_id IN (0, :from_store)";
|
||||
$conn->query($sql, ['new_store' => $newStoreId, 'from_store' => $fromStoreId]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool True if nginx reported reloaded
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magento\Framework\App;
|
||||
|
||||
/**
|
||||
* Minimal stub so PHPUnit can mock {@see \Magento\Framework\App\ResourceConnection} in unit tests.
|
||||
*/
|
||||
class ResourceConnection
|
||||
{
|
||||
public function getConnection(?string $resourceName = null): mixed
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getTableName(string $table): string
|
||||
{
|
||||
return $table;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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\App\ResourceConnection;
|
||||
use Magento\Store\Api\StoreRepositoryInterface;
|
||||
use Magento\Store\Model\ResourceModel\Store as StoreResource;
|
||||
use Magento\Store\Model\StoreFactory;
|
||||
@@ -82,6 +83,9 @@ class TunnelStoreProvisionerTest extends TestCase
|
||||
|
||||
$scopeConfig = $this->createMock(ScopeConfigInterface::class);
|
||||
|
||||
$resource = $this->createMock(ResourceConnection::class);
|
||||
$resource->expects(self::never())->method('getConnection');
|
||||
|
||||
$pidPath = $this->tmpDir . '/' . $tunnelId . '.pid';
|
||||
$keyPath = $this->tmpDir . '/' . $tunnelId . '.key';
|
||||
file_put_contents($pidPath, '12345');
|
||||
@@ -103,7 +107,8 @@ class TunnelStoreProvisionerTest extends TestCase
|
||||
$logger,
|
||||
$nginxMapManager,
|
||||
$scopeConfig,
|
||||
$tunnelProcessPaths
|
||||
$tunnelProcessPaths,
|
||||
$resource
|
||||
);
|
||||
|
||||
$provisioner->teardownTunnel($tunnelId);
|
||||
|
||||
@@ -12,9 +12,8 @@
|
||||
<label>Settings</label>
|
||||
<field id="mode" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
|
||||
<label>Secure Tunnel</label>
|
||||
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
|
||||
<default>1</default>
|
||||
<comment>Yes: private key mode (recommended). No: public discoverable key.</comment>
|
||||
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
|
||||
</field>
|
||||
<field id="script_path" translate="label" type="text" sortOrder="30" showInDefault="1">
|
||||
<label>Scripts directory</label>
|
||||
@@ -26,15 +25,13 @@
|
||||
</field>
|
||||
<field id="auto_restart" translate="label" type="select" sortOrder="40" showInDefault="1">
|
||||
<label>Auto-restart on crash</label>
|
||||
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
|
||||
<default>0</default>
|
||||
<comment>If Yes, the monitor cron restarts each tunnel whose process is no longer running (uses that tunnel's saved port and secure mode).</comment>
|
||||
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
|
||||
</field>
|
||||
<field id="monitor_cron_expr" translate="label" type="text" sortOrder="45" showInDefault="1" canRestore="1">
|
||||
<label>Monitor cron schedule</label>
|
||||
<config_path>crontab/default/jobs/magesail_monitor/schedule/cron_expr</config_path>
|
||||
<backend_model>MageSail\Magesail\Model\Config\Backend\MonitorCronExpr</backend_model>
|
||||
<comment>Magento cron expression for tunnel health monitor (stale PID cleanup and optional auto-restart). Default: once daily at midnight (0 0 * * *). Invalid expressions may prevent the job from running—flush config cache after changes.</comment>
|
||||
<backend_model>MageSail\Magesail\Model\Config\Backend\MonitorCronExpr</backend_model>
|
||||
</field>
|
||||
</group>
|
||||
<group id="nginx" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="1">
|
||||
@@ -73,9 +70,8 @@
|
||||
</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>
|
||||
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
|
||||
</field>
|
||||
</group>
|
||||
</section>
|
||||
|
||||
+6
-11
@@ -2,23 +2,18 @@
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
|
||||
<default>
|
||||
<magesail>
|
||||
<settings>
|
||||
<mode>1</mode>
|
||||
<auto_restart>0</auto_restart>
|
||||
<monitor_cron_expr>0 0 * * *</monitor_cron_expr>
|
||||
</settings>
|
||||
<nginx>
|
||||
<disable_base_url_redirect>1</disable_base_url_redirect>
|
||||
<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>
|
||||
<crontab>
|
||||
<default>
|
||||
<jobs>
|
||||
<magesail_monitor>
|
||||
<schedule>
|
||||
<cron_expr>0 0 * * *</cron_expr>
|
||||
</schedule>
|
||||
</magesail_monitor>
|
||||
</jobs>
|
||||
</default>
|
||||
</crontab>
|
||||
</default>
|
||||
</config>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
|
||||
<group id="default">
|
||||
<job name="magesail_monitor" instance="MageSail\Magesail\Cron\DailyMonitor" method="execute">
|
||||
<config_path>crontab/default/jobs/magesail_monitor/schedule/cron_expr</config_path>
|
||||
<config_path>magesail/settings/monitor_cron_expr</config_path>
|
||||
</job>
|
||||
</group>
|
||||
</config>
|
||||
|
||||
Reference in New Issue
Block a user