diff --git a/Model/AdminLogo.php b/Model/AdminLogo.php deleted file mode 100644 index b61dda5..0000000 --- a/Model/AdminLogo.php +++ /dev/null @@ -1,72 +0,0 @@ -moduleConfig = $moduleConfig; - $this->fileDriver = $fileDriver; - $this->urlBuilder = $urlBuilder; - } - - /** - * @return string|null - */ - public function getCustomAdminLoginLogoSrc(): ?string - { - if (!$logoFileName = $this->moduleConfig->getAdminLoginLogoFileName()) { - return null; - } - - return $this->fileDriver->getAbsolutePath( - $this->urlBuilder->getBaseUrl() . DirectoryList::MEDIA . DIRECTORY_SEPARATOR, - AdminLoginLogo::UPLOAD_DIR . DIRECTORY_SEPARATOR . $logoFileName - ); - } - - /** - * @return string|null - */ - public function getCustomAdminMenuLogoSrc(): ?string - { - if (!$logoFileName = $this->moduleConfig->getAdminMenuLogoFileName()) { - return null; - } - - return $this->fileDriver->getAbsolutePath( - $this->urlBuilder->getBaseUrl() . DirectoryList::MEDIA . DIRECTORY_SEPARATOR, - AdminMenuLogo::UPLOAD_DIR . DIRECTORY_SEPARATOR . $logoFileName - ); - } -} diff --git a/Model/Config/Backend/AdminLoginLogo.php b/Model/Config/Backend/AdminLoginLogo.php index 9e3eca6..1d232ef 100644 --- a/Model/Config/Backend/AdminLoginLogo.php +++ b/Model/Config/Backend/AdminLoginLogo.php @@ -1,7 +1,7 @@ _mediaDirectory->getAbsolutePath(self::UPLOAD_DIR); - } - /** * @inheritDoc */ diff --git a/Model/Config/Backend/AdminMenuLogo.php b/Model/Config/Backend/AdminMenuLogo.php index f03d88a..2200598 100644 --- a/Model/Config/Backend/AdminMenuLogo.php +++ b/Model/Config/Backend/AdminMenuLogo.php @@ -1,7 +1,7 @@ _mediaDirectory->getAbsolutePath(self::UPLOAD_DIR); - } - /** * @inheritDoc */ diff --git a/Plugin/Backend/Block/Page/HeaderPlugin.php b/Plugin/Backend/Block/Page/HeaderPlugin.php new file mode 100644 index 0000000..c8ba710 --- /dev/null +++ b/Plugin/Backend/Block/Page/HeaderPlugin.php @@ -0,0 +1,132 @@ +scopeConfig = $scopeConfig; + $this->storeManager = $storeManager; + $this->logger = $logger; + } + + /** + * Set custom logo URL on the block before rendering, if configured. + * + * Reads the config path and upload directory from layout XML arguments + * to determine which custom logo (login or menu) applies to this context. + * + * @param Header $subject + * @return void + */ + public function beforeToHtml(Header $subject): void + { + if ($subject->getData('show_part') !== 'logo') { + return; + } + + $configPath = $subject->getData('custom_logo_config_path'); + $uploadDir = $subject->getData('custom_logo_upload_dir'); + + if (!$configPath || !$uploadDir) { + return; + } + + $filename = $this->scopeConfig->getValue($configPath); + + if (!is_string($filename) || $filename === '') { + return; + } + + $filename = basename($filename); + + if ($filename === '') { + return; + } + + try { + $mediaUrl = $this->storeManager->getStore() + ->getBaseUrl(UrlInterface::URL_TYPE_MEDIA); + } catch (NoSuchEntityException $e) { + $this->logger->warning( + 'Element119_CustomAdminLogo: Unable to resolve store for media URL.', + ['exception' => $e] + ); + return; + } catch (\Throwable $e) { + $this->logger->error( + 'Element119_CustomAdminLogo: Unexpected error resolving media URL.', + ['exception' => $e] + ); + return; + } + + $subject->setLogoImageSrc($mediaUrl . $uploadDir . '/' . $filename); + } + + /** + * Pass through full URLs without asset repository resolution. + * + * When a custom logo is configured, logo_image_src is set to a full media + * URL. The core template passes this to getViewFileUrl(), which would + * attempt static file resolution. This plugin short-circuits that for + * absolute URLs, returning the fileId as-is. + * + * @param Header $subject + * @param string $result + * @param string $fileId + * @return string + */ + public function afterGetViewFileUrl( + Header $subject, + string $result, + string $fileId = '' + ): string { + if ($subject->getData('show_part') !== 'logo') { + return $result; + } + + if (str_starts_with($fileId, 'http://') || str_starts_with($fileId, 'https://')) { + return $fileId; + } + + return $result; + } +} diff --git a/Scope/Config.php b/Scope/Config.php deleted file mode 100644 index 391eec0..0000000 --- a/Scope/Config.php +++ /dev/null @@ -1,45 +0,0 @@ -scopeConfig = $scopeConfig; - } - - /** - * @return string|null - */ - public function getAdminLoginLogoFileName(): ?string - { - return $this->scopeConfig->getValue(self::XML_PATH_LOGIN_LOGO, ScopeInterface::SCOPE_STORE); - } - - /** - * @return string|null - */ - public function getAdminMenuLogoFileName(): ?string - { - return $this->scopeConfig->getValue(self::XML_PATH_MENU_LOGO, ScopeInterface::SCOPE_STORE); - } -} diff --git a/Test/Unit/Plugin/Backend/Block/Page/HeaderPluginTest.php b/Test/Unit/Plugin/Backend/Block/Page/HeaderPluginTest.php new file mode 100644 index 0000000..fabce88 --- /dev/null +++ b/Test/Unit/Plugin/Backend/Block/Page/HeaderPluginTest.php @@ -0,0 +1,350 @@ +scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->storeManager = $this->createMock(StoreManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->header = $this->getMockBuilder(Header::class) + ->disableOriginalConstructor() + ->addMethods(['setLogoImageSrc']) + ->onlyMethods(['getData']) + ->getMock(); + + $this->plugin = new HeaderPlugin( + $this->scopeConfig, + $this->storeManager, + $this->logger + ); + } + + /** + * Helper: configure getData to return the given map plus show_part=logo. + */ + private function setHeaderData(array $extra = []): void + { + $map = [['show_part', null, 'logo']]; + foreach ($extra as $key => $value) { + $map[] = [$key, null, $value]; + } + $this->header->method('getData')->willReturnMap($map); + } + + // ── show_part guard ────────────────────────────────────────────── + + public function testBeforeToHtmlSkipsNonLogoBlock(): void + { + $this->header->method('getData')->willReturnMap([ + ['show_part', null, 'user'], + ]); + + $this->scopeConfig->expects($this->never())->method('getValue'); + $this->header->expects($this->never())->method('setLogoImageSrc'); + + $this->plugin->beforeToHtml($this->header); + } + + public function testAfterGetViewFileUrlSkipsNonLogoBlock(): void + { + $this->header->method('getData')->willReturnMap([ + ['show_part', null, 'user'], + ]); + + $url = 'https://example.com/media/admin/logo/custom/menu/logo.png'; + $result = $this->plugin->afterGetViewFileUrl($this->header, 'original-result', $url); + + $this->assertSame('original-result', $result); + } + + // ── beforeToHtml guard clauses ─────────────────────────────────── + + public function testBeforeToHtmlDoesNothingWhenNoConfigPath(): void + { + $this->setHeaderData([ + 'custom_logo_config_path' => null, + 'custom_logo_upload_dir' => null, + ]); + + $this->scopeConfig->expects($this->never())->method('getValue'); + $this->header->expects($this->never())->method('setLogoImageSrc'); + + $this->plugin->beforeToHtml($this->header); + } + + public function testBeforeToHtmlDoesNothingWhenNoUploadDir(): void + { + $this->setHeaderData([ + 'custom_logo_config_path' => 'admin/e119_admin_logos/menu', + 'custom_logo_upload_dir' => null, + ]); + + $this->scopeConfig->expects($this->never())->method('getValue'); + $this->header->expects($this->never())->method('setLogoImageSrc'); + + $this->plugin->beforeToHtml($this->header); + } + + public function testBeforeToHtmlDoesNothingWhenNoFilenameInConfig(): void + { + $configPath = 'admin/e119_admin_logos/menu'; + + $this->setHeaderData([ + 'custom_logo_config_path' => $configPath, + 'custom_logo_upload_dir' => 'admin/logo/custom/menu', + ]); + + $this->scopeConfig->method('getValue') + ->with($configPath) + ->willReturn(null); + + $this->header->expects($this->never())->method('setLogoImageSrc'); + + $this->plugin->beforeToHtml($this->header); + } + + public function testBeforeToHtmlDoesNothingWhenFilenameIsEmptyString(): void + { + $configPath = 'admin/e119_admin_logos/menu'; + + $this->setHeaderData([ + 'custom_logo_config_path' => $configPath, + 'custom_logo_upload_dir' => 'admin/logo/custom/menu', + ]); + + $this->scopeConfig->method('getValue') + ->with($configPath) + ->willReturn(''); + + $this->header->expects($this->never())->method('setLogoImageSrc'); + + $this->plugin->beforeToHtml($this->header); + } + + public function testBeforeToHtmlDoesNothingWhenConfigReturnsNonString(): void + { + $configPath = 'admin/e119_admin_logos/menu'; + + $this->setHeaderData([ + 'custom_logo_config_path' => $configPath, + 'custom_logo_upload_dir' => 'admin/logo/custom/menu', + ]); + + $this->scopeConfig->method('getValue') + ->with($configPath) + ->willReturn(['unexpected' => 'array']); + + $this->header->expects($this->never())->method('setLogoImageSrc'); + + $this->plugin->beforeToHtml($this->header); + } + + // ── beforeToHtml security ──────────────────────────────────────── + + public function testBeforeToHtmlStripsPathTraversalFromFilename(): void + { + $configPath = 'admin/e119_admin_logos/menu'; + $uploadDir = 'admin/logo/custom/menu'; + $mediaUrl = 'https://example.com/media/'; + + $this->setHeaderData([ + 'custom_logo_config_path' => $configPath, + 'custom_logo_upload_dir' => $uploadDir, + ]); + + $this->scopeConfig->method('getValue') + ->with($configPath) + ->willReturn('../../etc/passwd'); + + $store = $this->createMock(Store::class); + $store->method('getBaseUrl') + ->with(UrlInterface::URL_TYPE_MEDIA) + ->willReturn($mediaUrl); + $this->storeManager->method('getStore')->willReturn($store); + + $this->header->expects($this->once()) + ->method('setLogoImageSrc') + ->with('https://example.com/media/admin/logo/custom/menu/passwd'); + + $this->plugin->beforeToHtml($this->header); + } + + // ── beforeToHtml happy path ────────────────────────────────────── + + public function testBeforeToHtmlSetsMenuLogoUrl(): void + { + $configPath = 'admin/e119_admin_logos/menu'; + $uploadDir = 'admin/logo/custom/menu'; + $filename = 'my-logo.png'; + $mediaUrl = 'https://example.com/media/'; + + $this->setHeaderData([ + 'custom_logo_config_path' => $configPath, + 'custom_logo_upload_dir' => $uploadDir, + ]); + + $this->scopeConfig->method('getValue') + ->with($configPath) + ->willReturn($filename); + + $store = $this->createMock(Store::class); + $store->method('getBaseUrl') + ->with(UrlInterface::URL_TYPE_MEDIA) + ->willReturn($mediaUrl); + $this->storeManager->method('getStore')->willReturn($store); + + $this->header->expects($this->once()) + ->method('setLogoImageSrc') + ->with('https://example.com/media/admin/logo/custom/menu/my-logo.png'); + + $this->plugin->beforeToHtml($this->header); + } + + public function testBeforeToHtmlSetsLoginLogoUrl(): void + { + $configPath = 'admin/e119_admin_logos/login'; + $uploadDir = 'admin/logo/custom/login'; + $filename = 'login-logo.jpg'; + $mediaUrl = 'https://example.com/media/'; + + $this->setHeaderData([ + 'custom_logo_config_path' => $configPath, + 'custom_logo_upload_dir' => $uploadDir, + ]); + + $this->scopeConfig->method('getValue') + ->with($configPath) + ->willReturn($filename); + + $store = $this->createMock(Store::class); + $store->method('getBaseUrl') + ->with(UrlInterface::URL_TYPE_MEDIA) + ->willReturn($mediaUrl); + $this->storeManager->method('getStore')->willReturn($store); + + $this->header->expects($this->once()) + ->method('setLogoImageSrc') + ->with('https://example.com/media/admin/logo/custom/login/login-logo.jpg'); + + $this->plugin->beforeToHtml($this->header); + } + + // ── beforeToHtml error handling ────────────────────────────────── + + public function testBeforeToHtmlHandlesNoSuchEntityException(): void + { + $configPath = 'admin/e119_admin_logos/menu'; + + $this->setHeaderData([ + 'custom_logo_config_path' => $configPath, + 'custom_logo_upload_dir' => 'admin/logo/custom/menu', + ]); + + $this->scopeConfig->method('getValue') + ->with($configPath) + ->willReturn('logo.png'); + + $this->storeManager->method('getStore') + ->willThrowException(new NoSuchEntityException(__('Store not found'))); + + $this->logger->expects($this->once()) + ->method('warning') + ->with( + 'Element119_CustomAdminLogo: Unable to resolve store for media URL.', + $this->arrayHasKey('exception') + ); + + $this->header->expects($this->never())->method('setLogoImageSrc'); + + $this->plugin->beforeToHtml($this->header); + } + + public function testBeforeToHtmlHandlesUnexpectedThrowable(): void + { + $configPath = 'admin/e119_admin_logos/menu'; + + $this->setHeaderData([ + 'custom_logo_config_path' => $configPath, + 'custom_logo_upload_dir' => 'admin/logo/custom/menu', + ]); + + $this->scopeConfig->method('getValue') + ->with($configPath) + ->willReturn('logo.png'); + + $this->storeManager->method('getStore') + ->willThrowException(new \RuntimeException('Unexpected failure')); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + 'Element119_CustomAdminLogo: Unexpected error resolving media URL.', + $this->arrayHasKey('exception') + ); + + $this->header->expects($this->never())->method('setLogoImageSrc'); + + $this->plugin->beforeToHtml($this->header); + } + + // ── afterGetViewFileUrl ────────────────────────────────────────── + + public function testAfterGetViewFileUrlPassesThroughHttpsUrl(): void + { + $this->setHeaderData(); + $url = 'https://example.com/media/admin/logo/custom/menu/logo.png'; + + $result = $this->plugin->afterGetViewFileUrl($this->header, 'ignored', $url); + + $this->assertSame($url, $result); + } + + public function testAfterGetViewFileUrlPassesThroughHttpUrl(): void + { + $this->setHeaderData(); + $url = 'http://example.com/media/admin/logo/custom/login/logo.png'; + + $result = $this->plugin->afterGetViewFileUrl($this->header, 'ignored', $url); + + $this->assertSame($url, $result); + } + + public function testAfterGetViewFileUrlReturnsOriginalResultForViewFile(): void + { + $this->setHeaderData(); + $resolvedUrl = 'https://example.com/static/adminhtml/Magento/backend/en_US/images/mage-os-icon.svg'; + + $result = $this->plugin->afterGetViewFileUrl( + $this->header, + $resolvedUrl, + 'images/mage-os-icon.svg' + ); + + $this->assertSame($resolvedUrl, $result); + } +} diff --git a/ViewModel/AdminLogo.php b/ViewModel/AdminLogo.php deleted file mode 100644 index 71ddea6..0000000 --- a/ViewModel/AdminLogo.php +++ /dev/null @@ -1,52 +0,0 @@ -adminLogo = $adminLogo; - $this->request = $request; - } - - /** - * @return AdminLogoModel - */ - public function getAdminLogoModel(): AdminLogoModel - { - return $this->adminLogo; - } - - /** - * @return bool - */ - public function isAdminLoginPage(): bool - { - return $this->request->getRouteName() === Area::AREA_ADMINHTML - && $this->request->getControllerName() === 'auth' - && $this->request->getActionName() === 'login'; - } -} diff --git a/composer.json b/composer.json index d3181dd..c2ae9fd 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,10 @@ } ], "require": { - "magento/module-config": "^101.0" + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-config": "^101.0", + "magento/module-store": "*" }, "autoload": { "psr-4": { diff --git a/etc/adminhtml/di.xml b/etc/adminhtml/di.xml new file mode 100644 index 0000000..1fec063 --- /dev/null +++ b/etc/adminhtml/di.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index eef10f8..0b1f620 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -2,7 +2,7 @@ + showInWebsite="0" + showInStore="0"> + + diff --git a/registration.php b/registration.php index 704de43..1ffb1c6 100644 --- a/registration.php +++ b/registration.php @@ -1,7 +1,7 @@ + + + + + + admin/e119_admin_logos/login + admin/logo/custom/login + + + + diff --git a/view/adminhtml/layout/default.xml b/view/adminhtml/layout/default.xml index d1c3773..9434fa8 100644 --- a/view/adminhtml/layout/default.xml +++ b/view/adminhtml/layout/default.xml @@ -2,7 +2,7 @@ - + - - Element119\CustomAdminLogo\ViewModel\AdminLogo - + admin/e119_admin_logos/menu + admin/logo/custom/menu diff --git a/view/adminhtml/templates/page/header.phtml b/view/adminhtml/templates/page/header.phtml deleted file mode 100644 index d962375..0000000 --- a/view/adminhtml/templates/page/header.phtml +++ /dev/null @@ -1,88 +0,0 @@ -getShowPart(); - -/** @var AdminLogo $adminLogoViewModel */ -$adminLogoViewModel = $block->getData('admin_logo_view_model'); -?> - - hasEdition() - ? 'data-edition="' . $escaper->escapeHtml($block->getEdition()) . '"' - : ''; ?> - getAdminLogoModel()->getCustomAdminLoginLogoSrc(); ?> - getAdminLogoModel()->getCustomAdminMenuLogoSrc(); ?> - - - class="logo"> - isAdminLoginPage()): ?> - src="escapeUrl( - $adminLoginLogoSrc ?: $block->getViewFileUrl('images/magento-logo.svg') - ); ?>" - - src="escapeUrl( - $adminMenuLogoSrc ?: $block->getViewFileUrl('images/magento-icon.svg') - ); ?>" - - alt="escapeHtmlAttr(__('Magento Admin Panel')); ?>" - title="escapeHtmlAttr(__('Magento Admin Panel')); ?>"/> - - - - - getChildHtml(); ?> - diff --git a/view/adminhtml/web/css/module.less b/view/adminhtml/web/css/module.less index 183a30a..1616b8e 100644 --- a/view/adminhtml/web/css/module.less +++ b/view/adminhtml/web/css/module.less @@ -1,6 +1,6 @@ /** * Copyright © element119. All rights reserved. - * See LICENCE.txt for licence details. + * See LICENSE for license details. */ .menu-wrapper a.logo .logo-img { height: auto;