diff --git a/Block/Adminhtml/Form.php b/Block/Adminhtml/Form.php index c56b2a9..fe7d545 100644 --- a/Block/Adminhtml/Form.php +++ b/Block/Adminhtml/Form.php @@ -4,10 +4,14 @@ * Please visit Magefan.com for license details (https://magefan.com/end-user-license-agreement). */ +declare(strict_types=1); + namespace Magefan\Cli\Block\Adminhtml; use Magento\Framework\View\Element\Template; use Magento\Framework\Console\CommandListInterface; +use Magento\Framework\View\Helper\SecureHtmlRenderer; +use Magento\Framework\Data\Form\FormKey; use Magefan\Cli\Model\Config; use Magento\Framework\AuthorizationInterface; @@ -28,12 +32,30 @@ class Form extends \Magento\Framework\View\Element\Template */ private $authorization; + /** + * @var \Magento\Framework\App\ResourceConnection + */ + private $resource; + + /** + * @var SecureHtmlRenderer + */ + private $secureHtmlRenderer; + + /** + * @var FormKey + */ + private $formKey; + /** * Form constructor. * @param Template\Context $context * @param CommandListInterface $commandList * @param Config $config * @param AuthorizationInterface $authorization + * @param \Magento\Framework\App\ResourceConnection $resource + * @param SecureHtmlRenderer|null $secureHtmlRenderer + * @param FormKey|null $formKey * @param array $data */ public function __construct( @@ -41,14 +63,63 @@ public function __construct( CommandListInterface $commandList, Config $config, AuthorizationInterface $authorization, + \Magento\Framework\App\ResourceConnection $resource, + ?SecureHtmlRenderer $secureHtmlRenderer = null, + ?FormKey $formKey = null, array $data = [] ) { $this->commandList = $commandList; $this->config = $config; $this->authorization = $authorization; + $this->resource = $resource; + $this->secureHtmlRenderer = $secureHtmlRenderer ?? \Magento\Framework\App\ObjectManager::getInstance()->get(SecureHtmlRenderer::class); + $this->formKey = $formKey ?? \Magento\Framework\App\ObjectManager::getInstance()->get(FormKey::class); parent::__construct($context, $data); } + /** + * Get form key for AJAX POST (required by Magento admin). + * @return string + */ + public function getFormKeyValue() + { + return $this->formKey->getFormKey(); + } + + /** + * Render script tag securely (works without Magefan_Community). + * @param string $script + * @return string + */ + public function renderScript($script) + { + return $this->secureHtmlRenderer->renderTag('script', [], $script, false); + } + + /** + * Return recent commands history (most recent first) + * @param int $limit + * @return array + */ + public function getCommandHistory($limit = 100) + { + try { + $connection = $this->resource->getConnection(); + $table = $this->resource->getTableName('magefan_cli_log'); + $select = $connection->select()->from($table, ['command'])->order('executed_at DESC')->limit((int)$limit); + $rows = $connection->fetchAll($select); + $commands = []; + foreach ($rows as $r) { + if (!empty($r['command'])) { + $commands[] = $r['command']; + } + } + return $commands; + } catch (\Exception $e) { + return []; + } + } + /** * Preparing global layout * diff --git a/Block/Adminhtml/History.php b/Block/Adminhtml/History.php new file mode 100644 index 0000000..37493c8 --- /dev/null +++ b/Block/Adminhtml/History.php @@ -0,0 +1,38 @@ +resource = $resource; + parent::__construct($context, $data); + } + + /** + * Get recent logs + * @param int $limit + * @return array + */ + public function getLogs($limit = 100) + { + $connection = $this->resource->getConnection(); + $table = $this->resource->getTableName('magefan_cli_log'); + $select = $connection->select()->from($table)->order('executed_at DESC')->limit((int)$limit); + return $connection->fetchAll($select); + } +} diff --git a/Block/Adminhtml/System/Config/Form/Info.php b/Block/Adminhtml/System/Config/Form/Info.php index ac4b091..2615913 100644 --- a/Block/Adminhtml/System/Config/Form/Info.php +++ b/Block/Adminhtml/System/Config/Form/Info.php @@ -8,24 +8,21 @@ namespace Magefan\Cli\Block\Adminhtml\System\Config\Form; -class Info extends \Magefan\Community\Block\Adminhtml\System\Config\Form\Info -{ - /** - * Return extension url - * @return string - */ - protected function getModuleUrl():string - { - return 'https://mage' . - 'fan.com/magento2-extensions?utm_source=m2admin_cli_config&utm_medium=link&utm_campaign=regular'; - } +use Magento\Config\Block\System\Config\Form\Field; +use Magento\Framework\Data\Form\Element\AbstractElement; +class Info extends Field +{ /** - * Return extension title + * @param AbstractElement $element * @return string */ - protected function getModuleTitle():string + protected function _getElementHtml(AbstractElement $element) { - return 'Command Line Interface'; + return '
'; } } diff --git a/Controller/Adminhtml/History/Index.php b/Controller/Adminhtml/History/Index.php new file mode 100644 index 0000000..9da40f2 --- /dev/null +++ b/Controller/Adminhtml/History/Index.php @@ -0,0 +1,35 @@ +resultPageFactory = $resultPageFactory; + parent::__construct($context); + } + + public function execute() + { + $resultPage = $this->resultPageFactory->create(); + $resultPage->setActiveMenu('Magefan_Cli::history'); + $resultPage->getConfig()->getTitle()->prepend(__('Command Line History')); + return $resultPage; + } +} diff --git a/Controller/Adminhtml/Index/Cli.php b/Controller/Adminhtml/Index/Cli.php index da02089..7fafee8 100644 --- a/Controller/Adminhtml/Index/Cli.php +++ b/Controller/Adminhtml/Index/Cli.php @@ -4,10 +4,13 @@ * Please visit Magefan.com for license details (https://magefan.com/end-user-license-agreement). */ +declare(strict_types=1); + namespace Magefan\Cli\Controller\Adminhtml\Index; use Magefan\Cli\Model\Config; use Magento\Framework\Data\Form\FormKey; +use Magento\Framework\App\ResourceConnection; class Cli extends \Magento\Backend\App\Action { @@ -45,6 +48,11 @@ class Cli extends \Magento\Backend\App\Action */ private $formKey; + /** + * @var ResourceConnection + */ + private $resource; + /** * Constructor * @@ -70,6 +78,7 @@ public function __construct( $this->authSession = $authSession; $this->config = $config; $this->formKey = $formKey; + $this->resource = $context->getObjectManager()->get(ResourceConnection::class); parent::__construct($context); } @@ -132,6 +141,25 @@ public function execute() if (!$message) { $message = __('Command not found or error occurred.') . PHP_EOL; } + // persist log to DB + try { + $connection = $this->resource->getConnection(); + $tableName = $this->resource->getTableName('magefan_cli_log'); + $userId = null; + try { + $user = $this->authSession->getUser(); + $userId = $user ? (int)$user->getId() : null; + } catch (\Exception $e) { + $userId = null; + } + + $connection->insertMultiple($tableName, [ + ['command' => $command, 'result' => $message, 'user_id' => $userId, 'executed_at' => (new \DateTime())->format('Y-m-d H:i:s')] + ]); + } catch (\Exception $e) { + // swallow DB errors to not break execution + } + unlink($logFile); } catch (\Exception $e) { $message = $e->getMessage() . PHP_EOL; diff --git a/Controller/Adminhtml/Index/Index.php b/Controller/Adminhtml/Index/Index.php index 924e8b8..2f950e7 100644 --- a/Controller/Adminhtml/Index/Index.php +++ b/Controller/Adminhtml/Index/Index.php @@ -4,6 +4,8 @@ * Please visit Magefan.com for license details (https://magefan.com/end-user-license-agreement). */ +declare(strict_types=1); + namespace Magefan\Cli\Controller\Adminhtml\Index; use Magefan\Cli\Model\Config; diff --git a/Controller/Adminhtml/MfcliHistory/Index.php b/Controller/Adminhtml/MfcliHistory/Index.php new file mode 100644 index 0000000..be9848e --- /dev/null +++ b/Controller/Adminhtml/MfcliHistory/Index.php @@ -0,0 +1,36 @@ +resultPageFactory = $resultPageFactory; + parent::__construct($context); + } + + public function execute() + { + $resultPage = $this->resultPageFactory->create(); + $resultPage->setActiveMenu('Magefan_Cli::history'); + $resultPage->getConfig()->getTitle()->prepend(__('Command Line History')); + return $resultPage; + } +} diff --git a/Controller/Adminhtml/MfcliIndex/Cli.php b/Controller/Adminhtml/MfcliIndex/Cli.php new file mode 100644 index 0000000..45d04e4 --- /dev/null +++ b/Controller/Adminhtml/MfcliIndex/Cli.php @@ -0,0 +1,199 @@ +resultPageFactory = $resultPageFactory; + $this->jsonHelper = $jsonHelper; + $this->dir = $dir; + $this->authSession = $authSession; + $this->config = $config; + $this->formKey = $formKey; + $this->resource = $context->getObjectManager()->get(ResourceConnection::class); + parent::__construct($context); + } + + /** + * Execute view action + * + * @return \Magento\Framework\Controller\ResultInterface|null + */ + public function execute() + { + try { + if (!$this->config->isEnabled()) { + throw new \Exception( + __(strrev('.ecafretnI eniL dnammoC > snoisnetxE nafegaM > noitarugifnoC > + serotS ot etagivan esaelp noisnetxe eht elbane ot ,delbasid si ecafretnI eniL dnammoC nafegaM')), + 1 + ); + } + + $this->validateUser(); + + $command = $this->getRequest()->getParam('command'); + $phpCommand = $this->config->getPhpCommand(); + + if (!$this->_authorization->isAllowed('Magefan_Cli::admin')) { + $needle = 'bin/magento '; + $position = stripos($command, $needle); + $needleLen = strlen($needle); + $commandStart = $position + $needleLen; + $magentoCommand = substr($command, $commandStart); + + if ($position === false || !in_array($magentoCommand, $this->config->getNonAdminCommands())) { + throw new \Exception(__('You don\'t have permission to execute this command.'), 1); + } + } + + if ($phpCommand) { + if (stripos($command, 'php ') === 0) { + $command = str_replace('php ', $phpCommand . ' ', $command); + } elseif (stripos($command, 'bin/magento') === 0) { + $command = $phpCommand . ' ' . $command; + } + } + + $blackCommands = ['admin:user']; + foreach ($blackCommands as $bc) { + if (strpos($command, $bc) !== false) { + throw new \Exception(__('Error: Cannot run this command due to security reasons.'), 1); + } + } + + if (strpos($command, 'cd') === 0) { + throw new \Exception(__('cd command is not supported.'), 1); + } + + $logFile = $this->dir->getPath('var') . '/mfcli.txt'; + exec($c = 'cd ' . $this->dir->getRoot() . ' && ' . $command . ' > ' . $logFile . ' 2>&1', $a, $b); + + $message = file_get_contents($logFile); + if (!$message) { + $message = __('Command not found or error occurred.') . PHP_EOL; + } + // persist log to DB + try { + $connection = $this->resource->getConnection(); + $tableName = $this->resource->getTableName('magefan_cli_log'); + $userId = null; + try { + $user = $this->authSession->getUser(); + $userId = $user ? (int)$user->getId() : null; + } catch (\Exception $e) { + $userId = null; + } + + $connection->insertMultiple($tableName, [ + ['command' => $command, 'result' => $message, 'user_id' => $userId, 'executed_at' => (new \DateTime())->format('Y-m-d H:i:s')] + ]); + } catch (\Exception $e) { + // swallow DB errors to not break execution + } + + unlink($logFile); + } catch (\Exception $e) { + $message = $e->getMessage() . PHP_EOL; + } + + $response = [ + 'message' => nl2br($message), + 'newFormKey' => $this->formKey->getFormKey() + ]; + + return $this->getResponse()->representJson( + $this->jsonHelper->jsonEncode($response) + ); + } + + /** + * Validate current user password + * + * @return $this + * @throws UserLockedException + * @throws \Magento\Framework\Exception\AuthenticationException + * @throws \Exception + */ + protected function validateUser() + { + $password = $this->getRequest()->getParam( + \Magento\User\Block\Role\Tab\Info::IDENTITY_VERIFICATION_PASSWORD_FIELD + ); + if (!$password) { + throw new \Exception(__('Please enter your password.')); + } + $user = $this->authSession->getUser(); + $user->performIdentityCheck($password); + + return $this; + } +} diff --git a/Controller/Adminhtml/MfcliIndex/Index.php b/Controller/Adminhtml/MfcliIndex/Index.php new file mode 100644 index 0000000..88d9613 --- /dev/null +++ b/Controller/Adminhtml/MfcliIndex/Index.php @@ -0,0 +1,41 @@ +resultPageFactory = $resultPageFactory; + $this->config = $config; + parent::__construct($context); + } + + public function execute() + { + $phpCommand = $this->config->getPhpCommand(); + if ($phpCommand && $this->config->isEnabled()) { + $this->messageManager->addNoticeMessage(__('Commands will be executed by custom PHP binary: ') . $phpCommand); + } + return $this->resultPageFactory->create(); + } +} diff --git a/Cron/Cleanup.php b/Cron/Cleanup.php new file mode 100644 index 0000000..f50bae2 --- /dev/null +++ b/Cron/Cleanup.php @@ -0,0 +1,65 @@ +resource = $resource; + $this->dateTime = $dateTime; + $this->scopeConfig = $scopeConfig; + $this->logger = $logger; + } + + public function execute() + { + try { + $days = (int)$this->scopeConfig->getValue(self::XML_PATH_LOG_LIFETIME) ?: 30; + $connection = $this->resource->getConnection(); + $table = $this->resource->getTableName('magefan_cli_log'); + $threshold = date('Y-m-d H:i:s', strtotime('-' . $days . ' days', $this->dateTime->gmtTimestamp())); + $where = ['executed_at < ?' => $threshold]; + $connection->delete($table, $where); + } catch (\Exception $e) { + $this->logger->error('Magefan_Cli cron cleanup error: ' . $e->getMessage()); + } + } +} diff --git a/etc/acl.xml b/etc/acl.xml old mode 100755 new mode 100644 diff --git a/etc/adminhtml/menu.xml b/etc/adminhtml/menu.xml index 4a8e5d6..0dd7034 100644 --- a/etc/adminhtml/menu.xml +++ b/etc/adminhtml/menu.xml @@ -7,6 +7,7 @@ -->| = $block->escapeHtml(__('ID')) ?> | += $block->escapeHtml(__('Command')) ?> | += $block->escapeHtml(__('Result')) ?> | += $block->escapeHtml(__('User ID')) ?> | += $block->escapeHtml(__('Executed At')) ?> | +
|---|---|---|---|---|
| = (int)$row['log_id'] ?> | += $block->escapeHtml($row['command']) ?> |
+ = $block->escapeHtml($row['result']) ?> |
+ = $row['user_id'] !== null ? (int)$row['user_id'] : '' ?> | += $block->escapeHtml($row['executed_at']) ?> | +