Admin Router Access

This commit is contained in:
2026-03-20 22:51:40 -05:00
parent 6b73c6f781
commit 942fbb2c60
7 changed files with 333 additions and 21 deletions
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Model;
use Magento\Framework\App\DeploymentConfig;
use Magento\Framework\App\Request\Http;
/**
* Detects Magento admin URL path so tunnel store scope is not applied to backend requests.
*/
class AdminPathDetector
{
public function __construct(
private readonly DeploymentConfig $deploymentConfig
) {
}
public function getConfiguredAdminFrontName(): string
{
return (string) $this->deploymentConfig->get('backend/frontName', 'admin');
}
/**
* True when the request path contains the configured admin front name as a segment (any position),
* after skipping empty, index.php, and pub segments.
*/
public function isAdminPath(Http $request): bool
{
return $this->getAdminFrontNameIfPresentInPath($request) !== null;
}
public function getAdminFrontNameIfPresentInPath(Http $request): ?string
{
$path = trim((string) $request->getPathInfo(), '/');
if ($path === '') {
$uriPath = parse_url((string) $request->getRequestUri(), PHP_URL_PATH);
$path = trim((string) $uriPath, '/');
}
if ($path === '') {
return null;
}
$adminFront = $this->getConfiguredAdminFrontName();
foreach (explode('/', $path) as $segment) {
$lower = strtolower($segment);
if ($lower === '' || $lower === 'index.php' || $lower === 'pub') {
continue;
}
if (strcasecmp($segment, $adminFront) === 0) {
return $adminFront;
}
}
return null;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Model;
use Magento\Framework\App\Request\Http;
/**
* Detects MageSail tunnel proxy (header / query / server) on the current request.
*/
class TunnelRequestDetector
{
private const HEADER_STORE_CODE = 'X-MageSail-Store-Code';
private const QUERY_PARAM_STORE = 'magesail_store';
private const SERVER_HEADER = 'HTTP_X_MAGESAIL_STORE_CODE';
public function getTunnelStoreCode(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(self::SERVER_HEADER);
if ($value !== null && $value !== '' && trim((string) $value) !== '') {
return trim((string) $value);
}
$uri = (string) $request->getRequestUri();
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);
}
}
return null;
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Plugin\App\Request;
use Magento\Framework\App\Request\Http;
use MageSail\Magesail\Model\AdminPathDetector;
/**
* {@see Http::getFrontName()} only uses the first path segment. With store code in URL, paths look like
* /magesail_tunnel/admin_xxx/… so Magento picks the wrong area. If any path segment is the configured admin
* front name, use it as the area front name.
*/
class AdminFrontNameFromPathPlugin
{
public function __construct(
private readonly AdminPathDetector $adminPathDetector
) {
}
public function aroundGetFrontName(Http $subject, callable $proceed): ?string
{
$adminFront = $this->adminPathDetector->getConfiguredAdminFrontName();
if ($adminFront === '') {
return $proceed();
}
$path = trim($subject->getPathInfo(), '/');
if ($path === '') {
return $proceed();
}
foreach (explode('/', $path) as $part) {
$p = strtolower($part);
if ($p === '' || $p === 'index.php' || $p === 'pub') {
continue;
}
if (strcasecmp($part, $adminFront) === 0) {
return $adminFront;
}
}
return $proceed();
}
}
@@ -4,16 +4,26 @@ declare(strict_types=1);
namespace MageSail\Magesail\Plugin\App\Request;
use Magento\Framework\App\Request\Http;
use Magento\Store\Model\ScopeInterface;
use Magento\Store\Model\StoreManager;
use MageSail\Magesail\Model\AdminPathDetector;
use MageSail\Magesail\Model\TunnelRequestDetector;
/**
* 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.
*
* Admin URLs must not use nginx tunnel map values: StoreResolver is built from MAGE_RUN_* init params; if they
* stay as the tunnel store, admin can 404. For admin paths we force website scope and empty run code so the
* default website is used (same as a normal request without per-host map).
*/
class StoreFromMageSailHeaderPlugin
{
private const HEADER_STORE_CODE = 'X-MageSail-Store-Code';
private const QUERY_PARAM_STORE = 'magesail_store';
public function __construct(
private readonly AdminPathDetector $adminPathDetector,
private readonly TunnelRequestDetector $tunnelRequestDetector
) {
}
/**
* Around getServerValue: if MageSail store header is set, return it for MAGE_RUN_CODE / MAGE_RUN_TYPE.
@@ -26,7 +36,16 @@ class StoreFromMageSailHeaderPlugin
*/
public function aroundGetServerValue(Http $subject, callable $proceed, $name = null, $default = null)
{
$storeCode = $this->getStoreCodeFromRequest($subject);
if ($this->adminPathDetector->isAdminPath($subject)) {
if ($name === StoreManager::PARAM_RUN_TYPE) {
return ScopeInterface::SCOPE_WEBSITE;
}
if ($name === StoreManager::PARAM_RUN_CODE) {
return '';
}
return $proceed($name, $default);
}
$storeCode = $this->tunnelRequestDetector->getTunnelStoreCode($subject);
if ($storeCode === null) {
return $proceed($name, $default);
}
@@ -38,21 +57,4 @@ class StoreFromMageSailHeaderPlugin
}
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,105 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Plugin\Backend\App\Area;
use Magento\Backend\App\Area\FrontNameResolver;
use Magento\Framework\App\Request\Http;
use Magento\Framework\App\RequestInterface;
use MageSail\Magesail\Model\TunnelRequestDetector;
use MageSail\Magesail\Model\TunnelStoreState;
/**
* When the tunnel is used, HTTP_HOST is often dev.myshop.local while default backend base URLs point at production.
* {@see FrontNameResolver::getFrontName(true)} then returns false, so {@see \Magento\Framework\App\AreaList}
* never maps the admin front name to adminhtml and /admin_* is dispatched as frontend (404).
*
* Bypass host validation when: (1) tunnel proxy headers/query are present, or (2) the request host matches the
* tunnel store base URLs from provisioned state (browser opened admin URL directly without X-MageSail-Store-Code).
*/
class TunnelBackendFrontNameResolverPlugin
{
public function __construct(
private readonly RequestInterface $request,
private readonly TunnelRequestDetector $tunnelRequestDetector,
private readonly TunnelStoreState $tunnelStoreState
) {
}
/**
* @param FrontNameResolver $subject
* @param callable(bool $checkHost=): string|false $proceed
* @return string|false
*/
public function aroundGetFrontName(FrontNameResolver $subject, callable $proceed, $checkHost = false)
{
$result = $proceed($checkHost);
if ($result !== false) {
return $result;
}
if (!$checkHost || !$this->request instanceof Http) {
return $result;
}
if (!$this->shouldBypassBackendHostCheck($this->request)) {
return $result;
}
if ($subject->isHostBackend()) {
return $result;
}
return $proceed(false);
}
private function shouldBypassBackendHostCheck(Http $request): bool
{
if ($this->tunnelRequestDetector->getTunnelStoreCode($request) !== null) {
return true;
}
return $this->requestHostMatchesTunnelStoreUrls($request);
}
/**
* Hostname-only match (tunnel unsecure/secure may differ in scheme/port from the current request).
*/
private function requestHostMatchesTunnelStoreUrls(Http $request): bool
{
$state = $this->tunnelStoreState->read();
if ($state === null) {
return false;
}
$requestHost = $this->hostFromHttpHost($request->getServer('HTTP_HOST'));
if ($requestHost === null) {
return false;
}
foreach ([$state['unsecure_base_url'], $state['secure_base_url']] as $baseUrl) {
if ($baseUrl === '') {
continue;
}
$configuredHost = $this->hostFromBaseUrl($baseUrl);
if ($configuredHost !== null && strcasecmp($requestHost, $configuredHost) === 0) {
return true;
}
}
return false;
}
private function hostFromHttpHost(?string $httpHost): ?string
{
if ($httpHost === null || $httpHost === '') {
return null;
}
$parsed = parse_url('http://' . $httpHost);
if ($parsed === false || !isset($parsed['host'])) {
return null;
}
return $parsed['host'];
}
private function hostFromBaseUrl(string $baseUrl): ?string
{
$parsed = parse_url($baseUrl);
if ($parsed === false || !isset($parsed['host'])) {
return null;
}
return $parsed['host'];
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace MageSail\Magesail\Plugin\Store;
use Magento\Framework\App\Request\Http;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\UrlInterface;
use Magento\Store\Model\Store;
use MageSail\Magesail\Model\TunnelRequestDetector;
/**
* With tunnel proxy, HTTP_HOST is dev.myshop.local but store base URLs point at production (e.g. s1.x64.world).
* Rewrite scheme/host for page/link base URLs only — never for static/media/js or asset bases break (404).
*/
class TunnelStoreBaseUrlPlugin
{
public function __construct(
private readonly RequestInterface $request,
private readonly TunnelRequestDetector $tunnelRequestDetector
) {
}
/**
* @param Store $subject
* @param string $result
* @return string
*/
public function afterGetBaseUrl(Store $subject, $result, $type = null, $secure = null)
{
if (!$this->request instanceof Http || $result === '' || $result === null) {
return $result;
}
$effectiveType = $type ?? UrlInterface::URL_TYPE_LINK;
if (!\in_array($effectiveType, [
UrlInterface::URL_TYPE_LINK,
UrlInterface::URL_TYPE_DIRECT_LINK,
UrlInterface::URL_TYPE_WEB,
], true)) {
return $result;
}
if ($this->tunnelRequestDetector->getTunnelStoreCode($this->request) === null) {
return $result;
}
$parts = parse_url($result);
if ($parts === false || empty($parts['host'])) {
return $result;
}
$requestHost = $this->request->getHttpHost();
if ($requestHost === '' || strcasecmp((string) $parts['host'], $requestHost) === 0) {
return $result;
}
$scheme = $this->request->isSecure() ? 'https' : 'http';
$path = $parts['path'] ?? '/';
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
$fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
return $scheme . '://' . $requestHost . $path . $query . $fragment;
}
}
+8 -1
View File
@@ -1,6 +1,13 @@
<?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"/>
<plugin name="magesail_admin_front_from_path" type="MageSail\Magesail\Plugin\App\Request\AdminFrontNameFromPathPlugin" sortOrder="5"/>
<plugin name="magesail_store_from_header" type="MageSail\Magesail\Plugin\App\Request\StoreFromMageSailHeaderPlugin" sortOrder="10"/>
</type>
<type name="Magento\Backend\App\Area\FrontNameResolver">
<plugin name="magesail_tunnel_backend_frontname" type="MageSail\Magesail\Plugin\Backend\App\Area\TunnelBackendFrontNameResolverPlugin"/>
</type>
<type name="Magento\Store\Model\Store">
<plugin name="magesail_tunnel_store_base_url" type="MageSail\Magesail\Plugin\Store\TunnelStoreBaseUrlPlugin"/>
</type>
</config>