Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
//ConfigBundle
Mautic.removeConfigValue = function(action, el) {
Mautic.executeAction(action, function(response) {
if (response.success) {
mQuery(el).parent().addClass('hide');
}
});
};
/**
*
* @returns string|false
*/
Mautic.parseQuery = function (query) {
var vars = query.split('&');
var queryString = {};
for (var i = 0; i < vars.length; i++) {
var pair = vars[i].split('=');
var key = decodeURIComponent(pair[0]);
var value = decodeURIComponent(pair[1]);
// If first entry with this name
if (typeof queryString[key] === 'undefined') {
queryString[key] = decodeURIComponent(value);
// If second entry with this name
} else if (typeof queryString[key] === 'string') {
var arr = [queryString[key], decodeURIComponent(value)];
queryString[key] = arr;
// If third or later entry with this name
} else {
queryString[key].push(decodeURIComponent(value));
}
}
return queryString;
}
Mautic.parseUrlHashParameter = function(url) {
var url = url.split('#');
if ('undefined' != typeof url[1]) {
return url[1];
}
return false;
}
Mautic.observeConfigTabs = function() {
if (!mQuery('#config_coreconfig_last_shown_tab').length) {
return;
}
var parameters = Mautic.parseQuery(window.location.search.substr(1));
if ('undefined' != typeof parameters['tab']) {
mQuery('#config_coreconfig_last_shown_tab').val(parameters['tab']);
mQuery('a[data-toggle="tab"]').each(function (i, tab) {
if (mQuery(tab).attr('href') == ('#' + parameters['tab'])) {
mQuery(tab).tab('show');
}
});
}
mQuery('a[data-toggle="tab"]').on('show.bs.tab', function (e) {
var tab = Mautic.parseUrlHashParameter(e.target.href);
if (tab) {
mQuery('#config_coreconfig_last_shown_tab').val(tab);
}
});
}
Mautic.resetEmailsToNotification = function(obj) {
const send_to_owner = obj.value;
if (parseInt(send_to_owner, 10) === 1)
{
mQuery(obj).closest('.panel-body').find('.notification_email_addresses').val('');
}
};
Mautic.configDsnTestExecute = function(element, action, key) {
const $button = mQuery(element),
$container = $button.closest('.config-dsn-container');
$container.find('.ri-loader-3-line').removeClass('hide');
Mautic.ajaxActionRequest(action, {key: key}, function(response) {
const theClass = (response.success) ? 'has-success' : 'has-error',
theMessage = response.message;
$container.find('.config-dsn-test-container').removeClass('has-success has-error').addClass(theClass);
$container.find('.help-block .status-msg').html(theMessage);
$container.find('.ri-loader-3-line').addClass('hide');
});
};
Mautic.configDsnTestDisable = function(element) {
const $container = mQuery(element).closest('.config-dsn-container');
$container.find('.help-block .status-msg').html('');
$container.find('.help-block .save-config-msg').removeClass('hide');
$container.find('.config-dsn-test-button').prop('disabled', true).addClass('disabled');
};
Mautic.showAnonymizeWarningMessage = function(anonymize_ip) {
if (mQuery(anonymize_ip).siblings('.toggle__label').attr('aria-checked') === 'true') {
mQuery('.anonymize_ip_address').addClass('hide');
} else {
mQuery('.anonymize_ip_address').removeClass('hide');
}
};
mQuery(Mautic.observeConfigTabs);

View File

@@ -0,0 +1,50 @@
<?php
return [
'routes' => [
'main' => [
'mautic_config_action' => [
'path' => '/config/{objectAction}/{objectId}',
'controller' => 'Mautic\ConfigBundle\Controller\ConfigController::executeAction',
],
'mautic_sysinfo_index' => [
'path' => '/sysinfo',
'controller' => 'Mautic\ConfigBundle\Controller\SysinfoController::indexAction',
],
],
],
'menu' => [
'admin' => [
'mautic.config.menu.index' => [
'route' => 'mautic_config_action',
'routeParameters' => ['objectAction' => 'edit'],
'iconClass' => 'ri-settings-5-line',
'id' => 'mautic_config_index',
'parent' => 'mautic.core.general',
'access' => 'admin',
'priority' => 16,
],
'mautic.sysinfo.menu.index' => [
'route' => 'mautic_sysinfo_index',
'iconClass' => 'ri-information-2-line',
'id' => 'mautic_sysinfo_index',
'parent' => 'mautic.core.general',
'access' => 'admin',
'priority' => 04,
'checks' => [
'parameters' => [
'sysinfo_disabled' => false,
],
],
],
],
],
'parameters' => [
'config_allowed_parameters' => [
'kernel.project_dir',
'kernel.logs_dir',
],
],
];

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
'Form/DataTransformer/DsnTransformer.php',
];
$services->load('Mautic\\ConfigBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->get(Mautic\ConfigBundle\Form\Type\EscapeTransformer::class)->arg('$allowedParameters', '%mautic.config_allowed_parameters%');
$services->get(Mautic\ConfigBundle\Form\Helper\RestrictionHelper::class)->arg('$restrictedFields', '%mautic.security.restrictedConfigFields%');
$services->get(Mautic\ConfigBundle\Form\Helper\RestrictionHelper::class)->arg('$displayMode', '%mautic.security.restrictedConfigFields.displayMode%');
// @deprecated Remove all aliases in Mautic 6. Use FQCN instead.
$services->alias('mautic.config.model.sysinfo', Mautic\ConfigBundle\Model\SysinfoModel::class);
$services->alias('mautic.config.mapper', Mautic\ConfigBundle\Mapper\ConfigMapper::class);
$services->alias('mautic.config.config_change_logger', Mautic\ConfigBundle\Service\ConfigChangeLogger::class);
$services->alias('mautic.config.form.escape_transformer', Mautic\ConfigBundle\Form\Type\EscapeTransformer::class);
$services->alias('mautic.config.form.restriction_helper', Mautic\ConfigBundle\Form\Helper\RestrictionHelper::class);
};

View File

@@ -0,0 +1,37 @@
<?php
namespace Mautic\ConfigBundle;
/**
* Events available for ConfigBundle.
*/
final class ConfigEvents
{
/**
* The mautic.config_on_generate event is thrown when the configuration form is generated.
*
* The event listener receives a
* Mautic\ConfigBundle\Event\ConfigGenerateEvent instance.
*
* @var string
*/
public const CONFIG_ON_GENERATE = 'mautic.config_on_generate';
/**
* The mautic.config_pre_save event is thrown right before config data are saved.
*
* The event listener receives a Mautic\ConfigBundle\Event\ConfigEvent instance.
*
* @var string
*/
public const CONFIG_PRE_SAVE = 'mautic.config_pre_save';
/**
* The mautic.config_post_save event is thrown right after config data are saved.
*
* The event listener receives a Mautic\ConfigBundle\Event\ConfigEvent instance.
*
* @var string
*/
public const CONFIG_POST_SAVE = 'mautic.config_post_save';
}

View File

@@ -0,0 +1,301 @@
<?php
namespace Mautic\ConfigBundle\Controller;
use Mautic\ConfigBundle\ConfigEvents;
use Mautic\ConfigBundle\Event\ConfigBuilderEvent;
use Mautic\ConfigBundle\Event\ConfigEvent;
use Mautic\ConfigBundle\Form\Type\ConfigType;
use Mautic\ConfigBundle\Mapper\ConfigMapper;
use Mautic\CoreBundle\Configurator\Configurator;
use Mautic\CoreBundle\Controller\FormController;
use Mautic\CoreBundle\Helper\BundleHelper;
use Mautic\CoreBundle\Helper\CacheHelper;
use Mautic\CoreBundle\Helper\EncryptionHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\UserBundle\Entity\User;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class ConfigController extends FormController
{
/**
* Controller action for editing the application configuration.
*
* @return JsonResponse|Response
*/
public function editAction(Request $request, BundleHelper $bundleHelper, Configurator $configurator, CacheHelper $cacheHelper, PathsHelper $pathsHelper, ConfigMapper $configMapper, TokenStorageInterface $tokenStorage)
{
// admin only allowed
if (!$this->user->isAdmin()) {
return $this->accessDenied();
}
$event = new ConfigBuilderEvent($bundleHelper);
$dispatcher = $this->dispatcher;
$dispatcher->dispatch($event, ConfigEvents::CONFIG_ON_GENERATE);
$fileFields = $event->getFileFields();
$formThemes = $event->getFormThemes();
$formConfigs = $configMapper->bindFormConfigsWithRealValues($event->getForms());
$this->mergeParamsWithLocal($formConfigs, $pathsHelper);
// Create the form
$action = $this->generateUrl('mautic_config_action', ['objectAction' => 'edit']);
$form = $this->formFactory->create(
ConfigType::class,
$formConfigs,
[
'action' => $action,
'fileFields' => $fileFields,
]
);
$originalNormData = $form->getNormData();
$isWritable = $configurator->isFileWritable();
$openTab = null;
// Check for a submitted form and process it
if ('POST' == $request->getMethod()) {
if (!$cancelled = $this->isFormCancelled($form)) {
$isValid = false;
if ($isWritable && $isValid = $this->isFormValid($form)) {
// Bind request to the form
$post = $request->request;
/** @var mixed[] $formData */
$formData = $form->getData();
// Dispatch pre-save event. Bundles may need to modify some field values like passwords before save
$configEvent = new ConfigEvent($formData, $post);
$configEvent
->setOriginalNormData($originalNormData)
->setNormData($form->getNormData());
$dispatcher->dispatch($configEvent, ConfigEvents::CONFIG_PRE_SAVE);
$formValues = $configEvent->getConfig();
$errors = $configEvent->getErrors();
$fieldErrors = $configEvent->getFieldErrors();
if ($errors || $fieldErrors) {
foreach ($errors as $message => $messageVars) {
$form->addError(
new FormError($this->translator->trans($message, $messageVars, 'validators'))
);
}
foreach ($fieldErrors as $key => $fields) {
foreach ($fields as $field => $fieldError) {
$form[$key][$field]->addError(
new FormError($this->translator->trans($fieldError[0], $fieldError[1], 'validators'))
);
}
}
$isValid = false;
} else {
// Prevent these from getting overwritten with empty values
$unsetIfEmpty = $configEvent->getPreservedFields();
$unsetIfEmpty = array_merge($unsetIfEmpty, $fileFields);
// Merge each bundle's updated configuration into the local configuration
foreach ($formValues as $object) {
$checkThese = array_intersect(array_keys($object), $unsetIfEmpty);
foreach ($checkThese as $checkMe) {
if (empty($object[$checkMe])) {
unset($object[$checkMe]);
}
}
$configurator->mergeParameters($object);
}
try {
// Ensure the config has a secret key
$params = $configurator->getParameters();
if (empty($params['secret_key'])) {
$configurator->mergeParameters(['secret_key' => EncryptionHelper::generateKey()]);
}
$configurator->write();
$dispatcher->dispatch($configEvent, ConfigEvents::CONFIG_POST_SAVE);
$this->addFlashMessage('mautic.config.config.notice.updated');
$cacheHelper->refreshConfig();
if (!empty($formData['coreconfig']['last_shown_tab'])) {
$openTab = $formData['coreconfig']['last_shown_tab'];
}
} catch (\RuntimeException $exception) {
$this->addFlashMessage('mautic.config.config.error.not.updated', ['%exception%' => $exception->getMessage()], 'error');
}
$this->setLocale($request, $tokenStorage, $params);
}
} elseif (!$isWritable) {
$form->addError(
new FormError(
$this->translator->trans('mautic.config.notwritable')
)
);
}
}
// If the form is saved or cancelled, redirect back to the dashboard
if ($cancelled || $isValid) {
if (!$cancelled && $this->isFormApplied($form)) {
$redirectParameters = ['objectAction' => 'edit'];
if ($openTab) {
$redirectParameters['tab'] = $openTab;
}
return $this->delegateRedirect($this->generateUrl('mautic_config_action', $redirectParameters));
} else {
return $this->delegateRedirect($this->generateUrl('mautic_dashboard_index'));
}
}
}
$tmpl = $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index';
return $this->delegateView(
[
'viewParameters' => [
'tmpl' => $tmpl,
'security' => $this->security,
'form' => $form->createView(),
'formThemes' => $formThemes,
'formConfigs' => $formConfigs,
'isWritable' => $isWritable,
],
'contentTemplate' => '@MauticConfig/Config/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_config_index',
'mauticContent' => 'config',
'route' => $this->generateUrl('mautic_config_action', ['objectAction' => 'edit']),
],
]
);
}
/**
* @return array|JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function downloadAction(Request $request, BundleHelper $bundleHelper, $objectId)
{
// admin only allowed
if (!$this->user->isAdmin()) {
return $this->accessDenied();
}
$event = new ConfigBuilderEvent($bundleHelper);
$dispatcher = $this->dispatcher;
$dispatcher->dispatch($event, ConfigEvents::CONFIG_ON_GENERATE);
// Extract and base64 encode file contents
$fileFields = $event->getFileFields();
if (!in_array($objectId, $fileFields)) {
return $this->accessDenied();
}
$content = $this->coreParametersHelper->get($objectId);
$filename = $request->get('filename', $objectId);
if ($decoded = base64_decode($content)) {
$response = new Response($decoded);
$response->headers->set('Content-Type', 'application/force-download');
$response->headers->set('Content-Type', 'application/octet-stream');
$response->headers->set('Content-Disposition', 'attachment; filename="'.$filename);
$response->headers->set('Expires', '0');
$response->headers->set('Cache-Control', 'must-revalidate');
$response->headers->set('Pragma', 'public');
return $response;
}
return $this->notFound();
}
/**
* @return array|JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function removeAction(BundleHelper $bundleHelper, Configurator $configurator, CacheHelper $cacheHelper, $objectId)
{
// admin only allowed
if (!$this->user->isAdmin()) {
return $this->accessDenied();
}
$success = 0;
$event = new ConfigBuilderEvent($bundleHelper);
$dispatcher = $this->dispatcher;
$dispatcher->dispatch($event, ConfigEvents::CONFIG_ON_GENERATE);
// Extract and base64 encode file contents
$fileFields = $event->getFileFields();
if (in_array($objectId, $fileFields)) {
$configurator->mergeParameters([$objectId => null]);
try {
$configurator->write();
$cacheHelper->refreshConfig();
$success = 1;
} catch (\Exception) {
}
}
return new JsonResponse(['success' => $success]);
}
/**
* Merges default parameters from each subscribed bundle with the local (real) params.
*/
private function mergeParamsWithLocal(array &$forms, PathsHelper $pathsHelper): void
{
$doNotChange = $this->coreParametersHelper->get('mautic.security.restrictedConfigFields');
$localConfigFile = $pathsHelper->getLocalConfigurationFile();
// Import the current local configuration, $parameters is defined in this file
$parameters = [];
include $localConfigFile;
/** @var mixed[] $parameters */
$localParams = $parameters;
foreach ($forms as &$form) {
// Merge the bundle params with the local params
foreach ($form['parameters'] as $key => $value) {
if (in_array($key, $doNotChange)) {
unset($form['parameters'][$key]);
} elseif (array_key_exists($key, $localParams)) {// @phpstan-ignore function.impossibleType (Not sure what this is about)
$paramValue = $localParams[$key];
$form['parameters'][$key] = $paramValue;
}
}
}
}
/**
* @param array<string, string> $params
*/
private function setLocale(Request $request, TokenStorageInterface $tokenStorage, array $params): void
{
$me = $tokenStorage->getToken()->getUser();
assert($me instanceof User);
$locale = $me->getLocale();
if (empty($locale)) {
$locale = $params['locale'] ?? $this->coreParametersHelper->get('locale');
}
$request->getSession()->set('_locale', $locale);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Mautic\ConfigBundle\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ConfigBundle\Model\SysinfoModel;
use Mautic\CoreBundle\Controller\FormController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\FormBundle\Helper\FormFieldHelper;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RequestStack;
class SysinfoController extends FormController
{
public function __construct(
FormFactoryInterface $formFactory,
FormFieldHelper $fieldHelper,
private SysinfoModel $sysinfoModel,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
UserHelper $userHelper,
CoreParametersHelper $coreParametersHelper,
EventDispatcherInterface $dispatcher,
Translator $translator,
FlashBag $flashBag,
RequestStack $requestStack,
CorePermissions $security,
) {
parent::__construct($formFactory, $fieldHelper, $doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
}
/**
* @return JsonResponse|\Symfony\Component\HttpFoundation\Response
*/
public function indexAction()
{
if (!$this->user->isAdmin() || $this->coreParametersHelper->get('sysinfo_disabled')) {
return $this->accessDenied();
}
return $this->delegateView([
'viewParameters' => [
'phpInfo' => $this->sysinfoModel->getPhpInfo(),
'requirements' => $this->sysinfoModel->getRequirements(),
'recommendations' => $this->sysinfoModel->getRecommendations(),
'folders' => $this->sysinfoModel->getFolders(),
'log' => $this->sysinfoModel->getLogTail(200),
'dbInfo' => $this->sysinfoModel->getDbInfo(),
],
'contentTemplate' => '@MauticConfig/Sysinfo/index.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_sysinfo_index',
'mauticContent' => 'sysinfo',
'route' => $this->generateUrl('mautic_sysinfo_index'),
],
]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\ConfigBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticConfigExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace Mautic\ConfigBundle\Event;
use Mautic\CoreBundle\Helper\BundleHelper;
use Symfony\Contracts\EventDispatcher\Event;
class ConfigBuilderEvent extends Event
{
/**
* @var mixed[]
*/
private array $forms = [];
/**
* @var string[]
*/
private array $formThemes = [
'@MauticConfig/FormTheme/_config_file_row.html.twig',
'@MauticConfig/FormTheme/dsn_row.html.twig',
];
/**
* @var string[]
*/
protected array $encodedFields = [];
public function __construct(
private BundleHelper $bundleHelper,
) {
}
/**
* Set new form to the forms array.
*
* @return $this
*/
public function addForm(array $form)
{
if (isset($form['formTheme'])) {
$this->formThemes[] = $form['formTheme'];
}
$this->forms[$form['formAlias']] = $form;
return $this;
}
/**
* Remove a form to the forms array.
*
* @param string $formAlias
*/
public function removeForm($formAlias): bool
{
if (isset($this->forms[$formAlias])) {
unset($this->forms[$formAlias]);
return true;
}
return false;
}
/**
* Returns the forms array.
*
* @return array
*/
public function getForms()
{
return $this->forms;
}
/**
* Returns the formThemes array.
*
* @return array
*/
public function getFormThemes()
{
return $this->formThemes;
}
/**
* Get default parameters from config defined in bundles.
*
* @return array
*/
public function getParametersFromConfig($bundle)
{
static $allBundles;
if (empty($allBundles)) {
$allBundles = $this->bundleHelper->getMauticBundles(true);
}
if (isset($allBundles[$bundle]) && $allBundles[$bundle]['config']['parameters']) {
return $allBundles[$bundle]['config']['parameters'];
} else {
return [];
}
}
/**
* @return $this
*/
public function addFileFields($fields)
{
$this->encodedFields = array_merge($this->encodedFields, (array) $fields);
return $this;
}
/**
* @return array
*/
public function getFileFields()
{
return $this->encodedFields;
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace Mautic\ConfigBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\ParameterBag;
class ConfigEvent extends CommonEvent
{
/**
* @var mixed[]
*/
private array $preserve = [];
/**
* @var mixed[]
*/
private array $errors = [];
/**
* @var mixed[]
*/
private array $fieldErrors = [];
/**
* Data got from build form before update.
*/
private ?array $originalNormData = null;
/**
* Data got from build form after update.
*
* @var array
*/
private $normData;
/**
* @param mixed[]|null $config
*/
public function __construct(
private ?array $config,
private ParameterBag $post,
) {
}
/**
* Returns the config array.
*
* @param string $key
*
* @return array
*/
public function getConfig($key = null)
{
if ($key) {
return $this->config[$key] ?? [];
}
return $this->config;
}
/**
* Sets the config array.
*
* @param string $key
*/
public function setConfig(array $config, $key = null): void
{
if ($key) {
$this->config[$key] = $config;
} else {
$this->config = $config;
}
}
public function getPost(): ParameterBag
{
return $this->post;
}
/**
* Set fields such as passwords that will not overwrite existing values
* if the current is empty.
*
* @param array|string $fields
*/
public function unsetIfEmpty($fields): void
{
if (!is_array($fields)) {
$fields = [$fields];
}
$this->preserve = array_merge($this->preserve, $fields);
}
/**
* Return array of fields to unset if empty so that existing values are not
* overwritten if empty.
*
* @return array
*/
public function getPreservedFields()
{
return $this->preserve;
}
/**
* Set error message.
*
* @param string $message (untranslated)
* @param array $messageVars for translation
* @param string|null $key
* @param string|null $field
*
* @return ConfigEvent
*/
public function setError($message, $messageVars = [], $key = null, $field = null)
{
if (!empty($key) && !empty($field)) {
if (!isset($this->errors[$key])) {
$this->fieldErrors[$key] = [];
}
$this->fieldErrors[$key][$field] = [
$message,
$messageVars,
];
return $this;
}
$this->errors[$message] = $messageVars;
return $this;
}
/**
* Get error messages.
*
* @return array
*/
public function getErrors()
{
return $this->errors;
}
/**
* @return array
*/
public function getFieldErrors()
{
return $this->fieldErrors;
}
public function getFileContent(UploadedFile $file): string
{
$tmpFile = $file->getRealPath();
$content = trim(file_get_contents($tmpFile));
@unlink($tmpFile);
return $content;
}
public function encodeFileContents($content): string
{
return base64_encode($content);
}
/**
* @return array
*/
public function getOriginalNormData()
{
return $this->originalNormData;
}
/**
* @return ConfigEvent
*/
public function setOriginalNormData(array $normData)
{
$this->originalNormData = $normData;
return $this;
}
/**
* @return array
*/
public function getNormData()
{
return $this->normData;
}
/**
* @param array $normData
*/
public function setNormData($normData): void
{
$this->normData = $normData;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Mautic\ConfigBundle\EventListener;
use Mautic\ConfigBundle\ConfigEvents;
use Mautic\ConfigBundle\Event\ConfigEvent;
use Mautic\ConfigBundle\Service\ConfigChangeLogger;
use Mautic\CoreBundle\Entity\AuditLogRepository;
use Mautic\CoreBundle\Entity\IpAddressRepository;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ConfigSubscriber implements EventSubscriberInterface
{
public function __construct(
private ConfigChangeLogger $configChangeLogger,
private IpAddressRepository $ipAddressRepository,
private CoreParametersHelper $coreParametersHelper,
private AuditLogRepository $auditLogRepository,
) {
}
public static function getSubscribedEvents(): array
{
return [
ConfigEvents::CONFIG_POST_SAVE => ['onConfigPostSave', 0],
];
}
public function onConfigPostSave(ConfigEvent $event): void
{
if ($originalNormData = $event->getOriginalNormData()) {
$normData = $event->getNormData();
// We have something to log
$this->configChangeLogger
->setOriginalNormData($originalNormData)
->log($normData);
if (!isset($originalNormData['trackingconfig']) && !isset($normData['trackingconfig'])) {
return;
}
$oldAnonymizeIp = $originalNormData['trackingconfig']['parameters']['anonymize_ip'];
$newAnonymizeIp = $normData['trackingconfig']['anonymize_ip'];
if ($oldAnonymizeIp !== $newAnonymizeIp && $newAnonymizeIp && !$this->coreParametersHelper->get('anonymize_ip_address_in_background', false)) {
$this->ipAddressRepository->anonymizeAllIpAddress();
$this->auditLogRepository->anonymizeAllIpAddress();
}
}
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Mautic\ConfigBundle\Exception;
class BadFormConfigException extends \Exception
{
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Mautic\ConfigBundle\Form\DataTransformer;
use Mautic\ConfigBundle\Form\Type\EscapeTransformer;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\Dsn\Dsn;
use Symfony\Component\Form\DataTransformerInterface;
/**
* @implements DataTransformerInterface<string, array>
*/
class DsnTransformer implements DataTransformerInterface
{
private const PASSWORD_MASK = '🔒';
public function __construct(
private CoreParametersHelper $coreParametersHelper,
private EscapeTransformer $escapeTransformer,
private string $configKey,
private bool $allowEmpty,
) {
}
/**
* @return array<string, mixed>
*/
public function transform($value): array
{
// unescape the DSN before the transformation to array
$value = $this->escapeTransformer->transform((string) $value);
try {
$dsn = Dsn::fromString($value);
} catch (\InvalidArgumentException) {
return [];
}
return [
'scheme' => $dsn->getScheme(),
'host' => $dsn->getHost(),
'user' => $dsn->getUser(),
'password' => $dsn->getPassword() ? self::PASSWORD_MASK : null,
'port' => $dsn->getPort(),
'path' => $dsn->getPath(),
'options' => $dsn->getOptions(),
];
}
/**
* @param array<string, mixed> $value
*/
public function reverseTransform($value): string
{
if ($this->allowEmpty && !array_filter($value)) {
return '';
}
// unescape the values as they are escaped by the escape transformer applied to the child elements
$value = $this->escapeTransformer->transform($value);
$dsn = new Dsn(
(string) $value['scheme'],
(string) $value['host'],
$value['user'],
$value['password'],
$value['port'] ? (int) $value['port'] : null,
$value['path'],
$value['options'],
);
if (self::PASSWORD_MASK === $dsn->getPassword()) {
$previousDsn = Dsn::fromString($this->coreParametersHelper->get($this->configKey));
$dsn = $dsn->setPassword($previousDsn->getPassword());
}
// escape the DSN to prevent "missing parameter" errors
return $this->escapeTransformer->reverseTransform((string) $dsn);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\ConfigBundle\Form\DataTransformer;
use Mautic\ConfigBundle\Form\Type\EscapeTransformer;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
class DsnTransformerFactory
{
public function __construct(
private CoreParametersHelper $coreParametersHelper,
private EscapeTransformer $escapeTransformer,
) {
}
public function create(string $configKey, bool $allowEmpty): DsnTransformer
{
return new DsnTransformer($this->coreParametersHelper, $this->escapeTransformer, $configKey, $allowEmpty);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Mautic\ConfigBundle\Form\Helper;
use Mautic\ConfigBundle\Mapper\Helper\RestrictionHelper as FieldHelper;
use Symfony\Component\Form\FormInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class RestrictionHelper
{
public const MODE_REMOVE = 'remove';
public const MODE_MASK = 'mask';
/**
* @var string[]
*/
private array $restrictedFields;
public function __construct(
private TranslatorInterface $translator,
array $restrictedFields,
private string $displayMode,
) {
$this->restrictedFields = FieldHelper::prepareRestrictions($restrictedFields);
}
/**
* @param FormInterface<mixed> $childType
* @param FormInterface<mixed> $parentType
*/
public function applyRestrictions(FormInterface $childType, FormInterface $parentType, ?array $restrictedFields = null): void
{
if (null === $restrictedFields) {
$restrictedFields = $this->restrictedFields;
}
$fieldName = $childType->getName();
if (array_key_exists($fieldName, $restrictedFields)) {
if (is_array($restrictedFields[$fieldName])) {
// Part of the collection of fields are restricted
foreach ($childType as $grandchild) {
$this->applyRestrictions($grandchild, $childType, $restrictedFields[$fieldName]);
}
return;
}
$this->restrictField($childType, $parentType);
}
}
/**
* @param FormInterface<mixed> $childType
* @param FormInterface<mixed> $parentType
*/
private function restrictField(FormInterface $childType, FormInterface $parentType): void
{
switch ($this->displayMode) {
case self::MODE_MASK:
$parentType->add(
$childType->getName(),
$childType->getConfig()->getType()->getInnerType()::class,
array_merge(
$childType->getConfig()->getOptions(),
[
'required' => false,
'mapped' => false,
'disabled' => true,
'attr' => array_merge($childType->getConfig()->getOptions()['attr'] ?? [], [
'placeholder' => $this->translator->trans('mautic.config.restricted'),
'readonly' => true,
]),
]
)
);
break;
case self::MODE_REMOVE:
$parentType->remove($childType->getName());
break;
}
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Mautic\ConfigBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
/**
* @extends AbstractType<mixed>
*/
class ConfigFileType extends AbstractType
{
public function getParent(): ?string
{
return FileType::class;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Mautic\ConfigBundle\Form\Type;
use Mautic\ConfigBundle\Form\Helper\RestrictionHelper;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<mixed>
*/
class ConfigType extends AbstractType
{
public function __construct(
private RestrictionHelper $restrictionHelper,
private EscapeTransformer $escapeTransformer,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// TODO very dirty quick fix for https://github.com/mautic/mautic/issues/8854
if (isset($options['data']['apiconfig']['parameters']['api_oauth2_access_token_lifetime'])
&& 3600 === $options['data']['apiconfig']['parameters']['api_oauth2_access_token_lifetime']
) {
$options['data']['apiconfig']['parameters']['api_oauth2_access_token_lifetime'] = 60;
}
if (isset($options['data']['apiconfig']['parameters']['api_oauth2_refresh_token_lifetime'])
&& 1_209_600 === $options['data']['apiconfig']['parameters']['api_oauth2_refresh_token_lifetime']
) {
$options['data']['apiconfig']['parameters']['api_oauth2_refresh_token_lifetime'] = 14;
}
foreach ($options['data'] as $config) {
if (isset($config['formAlias']) && !empty($config['parameters'])) {
$checkThese = array_intersect(array_keys($config['parameters']), $options['fileFields']);
foreach ($checkThese as $checkMe) {
// Unset base64 encoded values
unset($config['parameters'][$checkMe]);
}
$builder->add(
$config['formAlias'],
$config['formType'],
[
'data' => $config['parameters'],
]
);
$this->addTransformers($builder->get($config['formAlias']));
}
}
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event): void {
$form = $event->getForm();
foreach ($form as $configForm) {
foreach ($configForm as $child) {
$this->restrictionHelper->applyRestrictions($child, $configForm);
}
}
}
);
$builder->add(
'buttons',
FormButtonsType::class,
[
'apply_onclick' => 'Mautic.activateBackdrop()',
'save_onclick' => 'Mautic.activateBackdrop()',
]
);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'fileFields' => [],
]
);
}
private function addTransformers(FormBuilderInterface $builder): void
{
if (0 === $builder->count()) {
$builder->addModelTransformer($this->escapeTransformer);
return;
}
foreach ($builder as $childBuilder) {
$this->addTransformers($childBuilder);
}
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Mautic\ConfigBundle\Form\Type;
use Mautic\ConfigBundle\Form\DataTransformer\DsnTransformerFactory;
use Mautic\CoreBundle\Form\Type\SortableListType;
use Mautic\CoreBundle\Form\Type\StandAloneButtonType;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\Dsn\Dsn;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<array>
*/
class DsnType extends AbstractType
{
public function __construct(
private DsnTransformerFactory $dsnTransformerFactory,
private CoreParametersHelper $coreParametersHelper,
) {
}
/**
* @param FormBuilderInterface<array<mixed>|null> $builder
* @param array<string, mixed> $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$name = $builder->getName();
$onChange = 'Mautic.configDsnTestDisable(this)';
$attr = [
'class' => 'form-control',
'onchange' => $onChange,
];
$builder->add(
'scheme',
TextType::class,
[
'label' => 'mautic.config.dsn.scheme',
'required' => $options['required'],
'attr' => $attr,
]
);
$builder->add(
'host',
TextType::class,
[
'label' => 'mautic.config.dsn.host',
'required' => false,
'attr' => $attr,
]
);
$builder->add(
'port',
NumberType::class,
[
'label' => 'mautic.config.dsn.port',
'required' => false,
'html5' => true,
'attr' => $attr,
]
);
$builder->add(
'user',
TextType::class,
[
'label' => 'mautic.config.dsn.user',
'required' => false,
'attr' => $attr,
]
);
$builder->add(
'password',
TextType::class,
[
'label' => 'mautic.config.dsn.password',
'required' => false,
'attr' => $attr,
]
);
$builder->add(
'path',
TextType::class,
[
'label' => 'mautic.config.dsn.path',
'required' => false,
'attr' => $attr,
]
);
$builder->add(
'options',
SortableListType::class,
[
'required' => false,
'label' => 'mautic.config.dsn.options',
'attr' => [
'onchange' => $onChange,
],
'option_required' => false,
'with_labels' => true,
'key_value_pairs' => true,
]
);
if ($options['test_button']['action'] && $this->getCurrentDsn($name)) {
$builder->add(
'test_button',
StandAloneButtonType::class,
[
'label' => $options['test_button']['label'],
'required' => false,
'attr' => [
'class' => 'btn btn-tertiary btn-sm config-dsn-test-button',
'onclick' => sprintf('Mautic.configDsnTestExecute(this, "%s", "%s")', $options['test_button']['action'], $name),
],
]
);
}
$builder->addModelTransformer($this->dsnTransformerFactory->create($name, !$options['required']));
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'label' => false,
'error_mapping' => [
'.' => 'scheme',
],
'test_button' => [
'action' => null,
'label' => null,
],
]);
}
/**
* @phpstan-ignore-next-line
*/
public function finishView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['currentDsn'] = $this->getCurrentDsn($form->getName());
}
private function getCurrentDsn(string $name): ?Dsn
{
$dsn = (string) $this->coreParametersHelper->get($name);
try {
$dsn = Dsn::fromString($dsn);
} catch (\InvalidArgumentException) {
return null;
}
if ($dsn->getPassword()) {
$dsn = $dsn->setPassword('SECRET');
}
return $dsn;
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Mautic\ConfigBundle\Form\Type;
use Symfony\Component\Form\DataTransformerInterface;
/**
* @implements DataTransformerInterface<array<string|int|float|array<string|int|float>>|string|int|float, array<string|int|float|array<string|int|float>>|string|int|float>
*/
class EscapeTransformer implements DataTransformerInterface
{
/**
* @var string[]
*/
private array $allowedParameters;
public function __construct(array $allowedParameters)
{
$this->allowedParameters = array_filter($allowedParameters);
}
/**
* @param array<string|int|float|array<string|int|float>>|string|int|float $value
*
* @return array<string|int|float|array<string|int|float>>|string|int|float
*/
public function transform(mixed $value): mixed
{
if (is_array($value)) {
return array_map(fn ($value) => $this->unescape($value), $value);
}
return $this->unescape($value);
}
/**
* @param array<string|int|float|array<string|int|float>>|string|int|float $value
*
* @return array<string|int|float|array<string|int|float>>|string|int|float
*/
public function reverseTransform(mixed $value): mixed
{
if (is_array($value)) {
return array_map(fn ($value) => $this->escape($value), $value);
}
return $this->escape($value);
}
/**
* @param mixed $value
*
* @return mixed
*/
private function unescape($value)
{
if (!is_string($value)) {
return $value;
}
return str_replace('%%', '%', $value);
}
/**
* @param mixed $value
*
* @return mixed
*/
private function escape($value)
{
if (!is_string($value)) {
return $value;
}
$escaped = str_replace('%', '%%', $value);
return $this->allowParameters($escaped);
}
private function allowParameters(string $escaped): string
{
if (!$this->allowedParameters) {
return $escaped;
}
$search = array_map(fn (string $value): string => "%%{$value}%%", $this->allowedParameters);
$replace = array_map(fn (string $value): string => "%{$value}%", $this->allowedParameters);
return str_ireplace($search, $replace, $escaped);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Mautic\ConfigBundle\Mapper;
use Mautic\ConfigBundle\Exception\BadFormConfigException;
use Mautic\ConfigBundle\Mapper\Helper\ConfigHelper;
use Mautic\ConfigBundle\Mapper\Helper\RestrictionHelper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
class ConfigMapper
{
/**
* @var mixed[]
*/
private array $restrictedParameters;
public function __construct(
private CoreParametersHelper $parametersHelper,
array $restrictedParameters = [],
) {
$this->restrictedParameters = RestrictionHelper::prepareRestrictions($restrictedParameters);
}
/**
* @throws BadFormConfigException
*/
public function bindFormConfigsWithRealValues(array $forms): array
{
foreach ($forms as $bundle => $config) {
if (!isset($config['parameters'])) {
throw new BadFormConfigException();
}
$forms[$bundle]['parameters'] = $this->mergeWithLocalParameters($forms[$bundle]['parameters']);
}
return $forms;
}
/**
* Merges default parameters from each subscribed bundle with the local (real) params.
*/
private function mergeWithLocalParameters(array $formParameters): array
{
$formParameters = RestrictionHelper::applyRestrictions($formParameters, $this->restrictedParameters);
// All config values are stored at root level of the config
foreach ($formParameters as $formKey => $defaultValue) {
$configValue = $this->parametersHelper->get($formKey);
if (null === $configValue) {
// Nothing has been locally configured so keep default
continue;
}
// Form field is a collection of parameters
if (is_array($configValue)) {
// Apply nested restrictions to nested config values
$configValue = RestrictionHelper::applyRestrictions($configValue, $this->restrictedParameters, $formKey);
// Bind configured values with defaults
$formParameters[$formKey] = ConfigHelper::bindNestedConfigValues($configValue, $defaultValue);
continue;
}
// Form field
$formParameters[$formKey] = $configValue;
}
return $formParameters;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Mautic\ConfigBundle\Mapper\Helper;
class ConfigHelper
{
/**
* Map local config values with form fields.
*
* @param mixed $defaults
*/
public static function bindNestedConfigValues(array $configValues, $defaults): array
{
if (!is_array($defaults)) {
// Return all config values
return $configValues;
}
foreach ($defaults as $key => $defaultValue) {
if (isset($configValues[$key]) && is_array($configValues[$key])) {
$configValues[$key] = self::bindNestedConfigValues($configValues[$key], $defaultValue);
continue;
}
$configValues[$key] ??= $defaultValue;
}
return $configValues;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Mautic\ConfigBundle\Mapper\Helper;
class RestrictionHelper
{
/**
* Ensure that the array has string indexes for congruency with a nested array similar to ['db_host', 'monitored_email' => ['EmailBundle_bounces'];.
*/
public static function prepareRestrictions(array $restrictedParameters): array
{
$prepared = [];
foreach ($restrictedParameters as $key => $value) {
$newKey = (is_numeric($key)) ? $value : $key;
$prepared[$newKey] = (is_array($value)) ? self::prepareRestrictions($value) : $value;
}
return $prepared;
}
/**
* Remove fields that are restricted.
*/
public static function applyRestrictions(array $configParameters, array $restrictedParameters, $restrictedParentKey = null): array
{
if ($restrictedParentKey) {
if (!isset($restrictedParameters[$restrictedParentKey])) {
// No restrictions
return $configParameters;
}
$restrictedParameters = $restrictedParameters[$restrictedParentKey];
}
foreach ($configParameters as $key => $value) {
// The entire form type is restricted
if (isset($restrictedParameters[$key]) && !is_array($restrictedParameters[$key])) {
unset($configParameters[$key]);
continue;
}
// A sub type of the form type is restricted
if (is_array($value)) {
$configParameters[$key] = self::applyRestrictions($value, $restrictedParameters, $key);
continue;
}
// Otherwise no restrictions are in place
}
return $configParameters;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Mautic\ConfigBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MauticConfigBundle extends Bundle
{
}

View File

@@ -0,0 +1,178 @@
<?php
namespace Mautic\ConfigBundle\Model;
use Doctrine\DBAL\Connection;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\CoreBundle\Loader\ParameterLoader;
use Mautic\InstallBundle\Configurator\Step\CheckStep;
use Mautic\InstallBundle\Install\InstallService;
use Symfony\Contracts\Translation\TranslatorInterface;
class SysinfoModel
{
/**
* @var string|null
*/
protected $phpInfo;
/**
* @var array<string,bool>|null
*/
protected $folders;
public function __construct(
protected PathsHelper $pathsHelper,
protected CoreParametersHelper $coreParametersHelper,
private TranslatorInterface $translator,
protected Connection $connection,
private InstallService $installService,
private CheckStep $checkStep,
) {
}
/**
* Method to get the PHP info.
*
* @return string
*/
public function getPhpInfo()
{
if (!is_null($this->phpInfo)) {
return $this->phpInfo;
}
if (function_exists('phpinfo') && 'cli' !== php_sapi_name()) {
ob_start();
$currentTz = date_default_timezone_get();
date_default_timezone_set('UTC');
phpinfo(INFO_GENERAL | INFO_CONFIGURATION | INFO_MODULES);
$phpInfo = ob_get_contents();
ob_end_clean();
preg_match_all('#<body[^>]*>(.*)</body>#siU', $phpInfo, $output);
$output = preg_replace('#<table[^>]*>#', '<table class="table table-bordered">', $output[1][0]);
$output = preg_replace('#(\w),(\w)#', '\1, \2', $output);
$output = preg_replace('#<hr />#', '', $output);
$output = str_replace('<div class="center">', '', $output);
$output = preg_replace('#<tr class="h">(.*)<\/tr>#', '<thead><tr class="h">$1</tr></thead><tbody>', $output);
$output = str_replace('</table>', '</tbody></table>', $output);
$output = str_replace('</div>', '', $output);
$this->phpInfo = $output;
// ensure TZ is set back to default
date_default_timezone_set($currentTz);
} elseif (function_exists('phpversion')) {
$this->phpInfo = $this->translator->trans('mautic.sysinfo.phpinfo.phpversion', ['%phpversion%' => phpversion()]);
} else {
$this->phpInfo = $this->translator->trans('mautic.sysinfo.phpinfo.missing');
}
return $this->phpInfo;
}
/**
* @return string[]
*/
public function getRecommendations(): array
{
return $this->installService->checkOptionalSettings($this->checkStep);
}
/**
* @return string[]
*/
public function getRequirements(): array
{
return $this->installService->checkRequirements($this->checkStep);
}
/**
* Method to get important folders with a writable flag.
*
* @return array
*/
public function getFolders()
{
if (!is_null($this->folders)) {
return $this->folders;
}
$importantFolders = [
ParameterLoader::getLocalConfigFile($this->pathsHelper->getSystemPath('root').'/app'),
$this->coreParametersHelper->get('cache_path'),
$this->coreParametersHelper->get('log_path'),
$this->coreParametersHelper->get('upload_dir'),
$this->pathsHelper->getSystemPath('images', true),
$this->pathsHelper->getSystemPath('translations', true),
];
foreach ($importantFolders as $folder) {
$folderPath = realpath($folder);
$folderKey = $folderPath ?: $folder;
$isWritable = $folderPath && is_writable($folderPath);
$this->folders[$folderKey] = $isWritable;
}
return $this->folders;
}
/**
* Method to tail (a few last rows) of a file.
*
* @param int $lines
*/
public function getLogTail($lines = 10): ?string
{
$log = $this->coreParametersHelper->get('log_path').'/mautic_'.MAUTIC_ENV.'-'.date('Y-m-d').'.php';
if (!file_exists($log)) {
return null;
}
return $this->tail($log, $lines);
}
public function getDbInfo(): array
{
return [
'version' => $this->connection->executeQuery('SELECT VERSION()')->fetchOne(),
'driver' => $this->connection->getParams()['driver'],
'platform' => $this->connection->getDatabasePlatform()::class,
];
}
/**
* Method to tail (a few last rows) of a file.
*
* @param int $lines
* @param int $buffer
*/
public function tail($filename, $lines = 10, $buffer = 4096): string
{
$f = fopen($filename, 'rb');
$output = '';
fseek($f, -1, SEEK_END);
if ("\n" != fread($f, 1)) {
--$lines;
}
while (ftell($f) > 0 && $lines >= 0) {
$seek = min(ftell($f), $buffer);
fseek($f, -$seek, SEEK_CUR);
$output = ($chunk = fread($f, $seek)).$output;
fseek($f, -mb_strlen($chunk, '8bit'), SEEK_CUR);
$lines -= substr_count($chunk, "\n");
}
while ($lines++ < 0) {
$output = substr($output, strpos($output, "\n") + 1);
}
fclose($f);
return $output;
}
}

View File

@@ -0,0 +1,75 @@
{#
Variables
- tmpl
- security
- form
- formThemes
- formConfigs
- isWritable
#}
{% if formThemes is not empty and formThemes is iterable %}
{% form_theme form with formThemes %}
{% elseif formThemes is not empty and formThemes is string %}
{% form_theme form formThemes %}
{% endif %}
{% set isIndex = 'index' == tmpl ? true : false %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent %}config{% endblock %}
{% block headerTitle %}{{ 'mautic.config.header.index'|trans }}{% endblock %}
{% block content %}
<!-- start: box layout -->
<div class="container">
<!-- step container -->
<div class="row">
<div class="col-md-3 height-auto">
<div class="">
{% if not isWritable %}
<div class="alert alert-danger">{{ 'mautic.config.notwritable'|trans }}</div>
{% endif %}
<!-- Nav tabs -->
<ul class="list-group list-group-tabs" role="tablist">
{% for key in form.children|keys|filter(v => formConfigs[v] is defined and form[v].children|length > 0) %}
<li role="presentation" class="list-group-item {% if loop.first %}in active{% endif %}">
{% set containsErrors = formContainsErrors(form[key]) %}
<a href="#{{ key }}" aria-controls="{{ key }}" role="tab" data-toggle="tab" class="list-group-item-text steps {% if formContainsErrors(form[key]) %}text-danger{% endif %}">
{{ ('mautic.config.tab.' ~ key)|trans }}
{% if formContainsErrors(form[key]) %}
<i class="ri-alert-line"></i>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
<!-- container -->
<div class="col-md-9 height-auto">
{{ form_start(form) }}
<!-- Tab panes -->
<div class="tab-content">
{% for key in form.children|keys|filter(v => formConfigs[v] is defined) %}
{% if form[key].children|length > 0 %}
<div role="tabpanel" class="tab-pane fade {% if loop.first %}in active{% endif %} bdr-w-0" id="{{ key }}">
<div>
<div class="row pa-md bdr-b">
<h4 class="fw-sb">{{ ('mautic.config.tab.' ~ key)|trans }}</h4>
</div>
{{ form_widget(form[key], {'formConfig': formConfigs[key]}) }}
</div>
</div>
{% else %}
{% do form[key].setRendered() %}
{% endif %}
{% endfor %}
</div>
{{ form_end(form) }}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{%- set hasErrors = form.vars.errors|length %}
{%- set feedbackClass = hasErrors > 0 ? 'has-error' : '' %}
{%- set field = form.vars.name %}
{%- set hide = fieldValue is not defined or (fieldValue is defined and fieldValue is empty) ? 'hide' : '' %}
{%- set filename = inputAlphanum(form.vars.label|trans, true, '_') %}
{%- set downloadUrl = path('mautic_config_action', {'objectAction': 'download', 'objectId': field, 'filename': filename}) %}
{%- set removeUrl = path('mautic_config_action', {'objectAction': 'remove', 'objectId': field}) %}
<div class="row">
<div class="form-group col-xs-12 {{ feedbackClass }}">
{{ form_label(form, form.vars.label) }}
<span class="small pull-right {{ hide }}">
<a
data-toggle="confirmation"
href="{{ removeUrl }}"
data-message="{{ 'mautic.config.remove_file_contents'|trans|e }}"
data-confirm-text="{{ 'mautic.core.remove'|trans|e }}"
data-confirm-callback="removeConfigValue"
data-cancel-text="{{ 'mautic.core.form.cancel'|trans|e }}">
{{ 'mautic.core.remove'|trans }}
</a>
<span> | </span>
<a href="{{ downloadUrl }}">{{ 'mautic.core.download'|trans }}</a>
</span>
{{ form_widget(form) }}
{{ form_errors(form) }}
</div>
</div>

View File

@@ -0,0 +1,58 @@
{% block dsn_row %}
<div class="config-dsn-container">
{% if form.test_button is defined %}
<div class="help-block">
<span class="ri-loader-3-line ri-spin hide pull-left"></span>
<div class="status-msg"></div>
<div class="alert alert-warning save-config-msg hide">{{ 'mautic.config.dsn.save_to_test'|trans }}</div>
</div>
{% endif %}
<div class="row">
<div class="col-xs-12">
{{ form_row(form.scheme) }}
</div>
<div class="col-xs-12">
{{ form_row(form.host, { 'attr': {'preaddon_text': '://'} }) }}
</div>
<div class="col-xs-12">
{{ form_row(form.port, { 'attr': {'preaddon_text': ':'} }) }}
</div>
<div class="col-xs-12">
{{ form_row(form.path, { 'attr': {'preaddon_text': '/'} }) }}
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="row">
<div class="col-xs-12">
{{ form_row(form.user) }}
</div>
<div class="col-xs-12">
{{ form_row(form.password, { 'attr': {'preaddon_text': ':'} }) }}
</div>
</div>
<div class="config-dsn-test-container">
<div class="form-group">
{% if form.test_button is defined %}
{{ form_widget(form.test_button) }}
{% endif %}
</div>
<div class="form-group">
<div class="form-control-static ml-10">
<span class="text-muted">{{ 'mautic.config.dsn.using_current_dsn'|trans }}:</span>
{% include '@MauticCore/Components/code-snippet.html.twig' with {
variant: 'inline',
innerText: form.vars.currentDsn|default('n/a'),
} %}
</div>
</div>
</div>
</div>
<hr>
<div class="col-xs-12">
{{ form_row(form.options) }}
</div>
</div>
{{ form_rest(form) }}
</div>
{% endblock %}

View File

@@ -0,0 +1,142 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}sysinfo{% endblock %}
{% block headerTitle %}{{ 'mautic.sysinfo.header.index'|trans }}{% endblock %}
{% block content %}
<!-- start: box layout -->
<div class="box-layout">
<!-- step container -->
<div class="col-md-3 height-auto">
<div class="pr-lg pl-lg pt-md pb-md">
<!-- Nav tabs -->
<ul class="list-group list-group-tabs" role="tablist">
<li role="presentation" class="list-group-item in active">
<a href="#phpinfo" aria-controls="phpinfo" role="tab" data-toggle="tab" class="list-group-item-heading">
{{ 'mautic.sysinfo.tab.phpinfo'|trans }}
</a>
</li>
<li role="presentation" class="list-group-item">
<a href="#recommendations" aria-controls="phpinfo" role="tab" data-toggle="tab" class="list-group-item-heading">
{{ 'mautic.sysinfo.tab.recommendations'|trans }}
</a>
</li>
<li role="presentation" class="list-group-item">
<a href="#folders" aria-controls="folders" role="tab" data-toggle="tab" class="list-group-item-heading">
{{ 'mautic.sysinfo.tab.folders'|trans }}
</a>
</li>
<li role="presentation" class="list-group-item">
<a href="#log" aria-controls="log" role="tab" data-toggle="tab" class="list-group-item-heading">
{{ 'mautic.sysinfo.tab.log'|trans }}
</a>
</li>
<li role="presentation" class="list-group-item">
<a href="#dbinfo" aria-controls="dbinfo" role="tab" data-toggle="tab" class="list-group-item-heading">
{{ 'mautic.sysinfo.tab.dbinfo'|trans }}
</a>
</li>
</ul>
</div>
</div>
<!-- container -->
<div class="col-md-9 height-auto bdr-l">
<!-- Tab panes -->
<div class="tab-content">
<div role="tabpanel" class="tab-pane fade in active bdr-w-0" id="phpinfo">
<div class="pt-md pr-md pl-md pb-md">
{{ phpInfo|raw }}
</div>
</div>
<div role="tabpanel" class="tab-pane fade bdr-w-0" id="recommendations">
<div class="pt-md pr-md pl-md pb-md">
{% if recommendations is empty and requirements is empty %}
<div class="alert alert-info">
{{ 'mautic.sysinfo.no.recommendations'|trans }}
</div>
{% endif %}
{% for requirement in requirements %}
<div class="alert alert-danger">
{{ requirement|raw }}
</div>
{% endfor %}
{% for recommendation in recommendations %}
<div class="alert alert-warning">
{{ recommendation|raw }}
</div>
{% endfor %}
</div>
</div>
<div role="tabpanel" class="tab-pane fade bdr-w-0" id="folders">
<div class="pt-md pr-md pl-md pb-md">
<h2 class="pb-md">{{ 'mautic.sysinfo.folders.title'|trans }}</h2>
<table class="table table-hover">
<thead>
<tr>
<th>{{ 'mautic.sysinfo.folder.path'|trans }}</th>
<th>{{ 'mautic.sysinfo.is.writable'|trans }}</th>
</tr>
</thead>
{% for folder, isWritable in folders %}
<tr class="{% if isWritable %}bg-success{% else %}bg-danger{% endif %}">
<td>{{ folder }}</td>
<td>
{% if isWritable %}
{{ 'mautic.sysinfo.writable'|trans }}
{% else %}
{{ 'mautic.sysinfo.unwritable'|trans }}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div role="tabpanel" class="tab-pane fade bdr-w-0" id="log">
<div class="pt-md pr-md pl-md pb-md">
<h2 class="pb-md">{{ 'mautic.sysinfo.log.title'|trans }}</h2>
{% if log is defined and log is not empty %}
<pre>{{ log }}</pre>
{% else %}
<div class="alert alert-info" role="alert">
{{ 'mautic.sysinfo.log.missing'|trans }}
</div>
{% endif %}
</div>
</div>
<div role="tabpanel" class="tab-pane fade bdr-w-0" id="dbinfo">
<div class="pt-md pr-md pl-md pb-md">
<h2 class="pb-md">{{ 'mautic.sysinfo.dbinfo.title'|trans }}</h2>
<table class="table table-bordered">
<thead>
<tr>
<th>{{ 'mautic.sysinfo.dbinfo.property'|trans }}</th>
<th>{{ 'mautic.sysinfo.dbinfo.value'|trans }}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ 'mautic.sysinfo.dbinfo.version'|trans }}</td>
<td id="dbinfo-version">{{ dbInfo.version }}</td>
</tr>
<tr>
<td>{{ 'mautic.sysinfo.dbinfo.driver'|trans }}</td>
<td id="dbinfo-driver">{{ dbInfo.driver }}</td>
</tr>
<tr>
<td>{{ 'mautic.sysinfo.dbinfo.platform'|trans }}</td>
<td id="dbinfo-platform">{{ dbInfo.platform }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,117 @@
<?php
namespace Mautic\ConfigBundle\Service;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Compare normalized for data and log changes.
*/
class ConfigChangeLogger
{
/**
* Keys to remove from log.
*
* @var string[]
*/
private array $filterKeys = [
'transifex_password',
'mailer_is_owner',
];
/**
* @var mixed[]|null
*/
private ?array $originalNormData = null;
public function __construct(
private IpLookupHelper $ipLookupHelper,
private AuditLogModel $auditLogModel,
) {
}
/**
* @return ConfigChangeLogger
*/
public function setOriginalNormData(array $originalNormData)
{
$this->originalNormData = $originalNormData;
return $this;
}
/**
* Log changes if something was changed.
* Diff is based on form normalized data before and after post.
*
* @see Form::getNormData()
*/
public function log(array $postNormData): void
{
if (null === $this->originalNormData) {
throw new \RuntimeException('Set original normalized data at first');
}
$originalData = $this->normalizeData($this->originalNormData);
$postData = $this->filterData($this->normalizeData($postNormData));
$diff = [];
foreach ($postData as $key => $value) {
if (array_key_exists($key, $originalData) && $originalData[$key] != $value) {
if ($value instanceof UploadedFile) {
$value = $value->getFilename();
}
$diff[$key] = $value;
}
}
if (empty($diff)) {
return;
}
$log = [
'bundle' => 'config',
'object' => 'config',
'objectId' => 0,
'action' => 'update',
'details' => $diff,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
/**
* Some form data (AssetBundle) has 'parameters' inside array too.
* Normalize all.
*/
private function normalizeData(array $data): array
{
$key = 'parameters';
$normData = [];
foreach ($data as $values) {
if (array_key_exists($key, $values)) {
$normData = array_merge($normData, $values[$key]);
} else {
$normData = array_merge($normData, $values);
}
}
return $normData;
}
/**
* Filter unused keys from post data.
*/
private function filterData(array $data): array
{
$keys = $this->filterKeys;
return array_filter($data, fn ($key): bool => !in_array($key, $keys),
ARRAY_FILTER_USE_KEY);
}
}

View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace Mautic\ConfigBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use PHPUnit\Framework\Assert;
use Symfony\Component\DomCrawler\Field\ChoiceFormField;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ConfigControllerFunctionalTest extends MauticMysqlTestCase
{
private const SUBDOMAIN_URL = 'subdomain_url.com';
private string $prefix;
protected $useCleanupRollback = false;
protected function setUp(): void
{
$this->configParams['config_allowed_parameters'] = [
'kernel.project_dir',
];
$this->configParams['locale'] = 'en_US';
$this->configParams['subdomain_url'] = self::SUBDOMAIN_URL;
parent::setUp();
$this->prefix = MAUTIC_TABLE_PREFIX;
}
public function testValuesAreEscapedProperly(): void
{
$trackIps = "%ip1%\n%ip2%\n%kernel.project_dir%";
$googleAnalytics = 'reveal pass: %mautic.db_password%';
// request config edit page
$crawler = $this->client->request(Request::METHOD_GET, '/s/config/edit');
$this->assertResponseIsSuccessful();
// Find save & close button
$buttonCrawler = $crawler->selectButton('config[buttons][save]');
$form = $buttonCrawler->form();
$form->setValues(
[
'config[coreconfig][site_url]' => 'https://mautic-community.local', // required
'config[coreconfig][do_not_track_ips]' => $trackIps,
'config[pageconfig][google_analytics]' => $googleAnalytics,
'config[leadconfig][contact_columns]' => ['name', 'email', 'id'],
]
);
$crawler = $this->client->submit($form);
$this->assertResponseIsSuccessful();
// Check for a flash error
$response = $this->client->getResponse()->getContent();
$message = $crawler->filterXPath("//div[@id='flashes']//span")->count()
?
$crawler->filterXPath("//div[@id='flashes']//span")->first()->text()
:
'';
Assert::assertStringNotContainsString('Could not save updated configuration:', $response, $message);
// Check values are escaped properly in the config file
$configParameters = $this->getConfigParameters();
Assert::assertArrayHasKey('do_not_track_ips', $configParameters);
Assert::assertSame(
[
$this->escape('%ip1%'),
$this->escape('%ip2%'),
'%kernel.project_dir%',
],
$configParameters['do_not_track_ips']
);
Assert::assertArrayHasKey('google_analytics', $configParameters);
Assert::assertSame($this->escape($googleAnalytics), $configParameters['google_analytics']);
// Check values are unescaped properly in the edit form
$crawler = $this->client->request(Request::METHOD_GET, '/s/config/edit');
$this->assertResponseIsSuccessful();
$buttonCrawler = $crawler->selectButton('config[buttons][save]');
$form = $buttonCrawler->form();
Assert::assertEquals($trackIps, $form['config[coreconfig][do_not_track_ips]']->getValue());
Assert::assertEquals($googleAnalytics, $form['config[pageconfig][google_analytics]']->getValue());
}
private function getConfigPath(): string
{
return static::getContainer()->get('kernel')->getLocalConfigFile();
}
private function getConfigParameters(): array
{
$parameters = [];
include $this->getConfigPath();
return $parameters;
}
private function escape(string $value): string
{
return str_replace('%', '%%', $value);
}
public function testConfigNotFoundPageConfiguration(): void
{
// insert published record
$this->connection->insert($this->prefix.'pages', [
'is_published' => 1,
'date_added' => (new \DateTime())->format('Y-m-d H:i:s'),
'title' => 'page1',
'alias' => 'page1',
'template' => 'blank',
'custom_html' => 'Page1 Test Html',
'hits' => 0,
'unique_hits' => 0,
'variant_hits' => 0,
'revision' => 0,
'lang' => 'en',
]);
$page1 = $this->connection->lastInsertId();
// insert unpublished record
$this->connection->insert($this->prefix.'pages', [
'is_published' => 0,
'date_added' => (new \DateTime())->format('Y-m-d H:i:s'),
'title' => 'page2',
'alias' => 'page2',
'template' => 'blank',
'custom_html' => 'Page2 Test Html',
'hits' => 0,
'unique_hits' => 0,
'variant_hits' => 0,
'revision' => 0,
'lang' => 'en',
]);
$this->connection->lastInsertId();
// insert published record
$this->connection->insert($this->prefix.'pages', [
'is_published' => 1,
'date_added' => (new \DateTime())->format('Y-m-d H:i:s'),
'title' => 'page3',
'alias' => 'page3',
'template' => 'blank',
'custom_html' => 'Page3 Test Html',
'hits' => 0,
'unique_hits' => 0,
'variant_hits' => 0,
'revision' => 0,
'lang' => 'en',
]);
$page3 = $this->connection->lastInsertId();
// request config edit page
$crawler = $this->client->request(Request::METHOD_GET, '/s/config/edit');
// Find save & close button
$buttonCrawler = $crawler->selectButton('config[buttons][save]');
$form = $buttonCrawler->form();
// Fetch available option for 404_page field
$availableOptions = $form['config[coreconfig][404_page]']->availableOptionValues();
// page 2 should not be available in option list because it is unpublished
$this->assertEquals(['', $page1, $page3], $availableOptions);
// page 3 for 404_page
$form->setValues(
[
'config[coreconfig][site_url]' => 'https://mautic-community.local', // required
'config[leadconfig][contact_columns]' => ['name', 'email', 'id'],
'config[coreconfig][404_page]' => $page3,
]
);
$crawler = $this->client->submit($form);
$this->assertResponseIsSuccessful();
$crawler = $this->client->request(Request::METHOD_GET, '/s/config/edit');
$this->assertResponseIsSuccessful();
$buttonCrawler = $crawler->selectButton('config[buttons][save]');
$form = $buttonCrawler->form();
Assert::assertEquals($page3, $form['config[coreconfig][404_page]']->getValue());
// re-create the Symfony client to make config changes applied
$this->setUpSymfony($this->configParams);
// Request not found url page3 page content should be rendered
$crawler = $this->client->request(Request::METHOD_GET, '/notfoundurlblablabla');
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
$this->assertStringContainsString('Page3 Test Html', $crawler->text());
}
public function testConfigNotificationConfiguration(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/config/edit');
$buttonCrawler = $crawler->selectButton('config[buttons][save]');
$form = $buttonCrawler->form();
$send_notification_to_author = '0';
$campaign_notification_email_addresses = 'a@test.com, b@test.com';
$webhook_notification_email_addresses = 'a@webhook.com, b@webhook.com';
$form->setValues(
[
'config[coreconfig][site_url]' => 'https://mautic-community.local', // required
'config[leadconfig][contact_columns]' => ['name', 'email', 'id'],
'config[notification_config][campaign_send_notification_to_author]' => $send_notification_to_author,
'config[notification_config][campaign_notification_email_addresses]' => $campaign_notification_email_addresses,
'config[notification_config][webhook_send_notification_to_author]' => $send_notification_to_author,
'config[notification_config][webhook_notification_email_addresses]' => $webhook_notification_email_addresses,
]
);
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$crawler = $this->client->request(Request::METHOD_GET, '/s/config/edit');
$this->assertResponseIsSuccessful();
$buttonCrawler = $crawler->selectButton('config[buttons][save]');
$form = $buttonCrawler->form();
Assert::assertEquals($send_notification_to_author, $form['config[notification_config][campaign_send_notification_to_author]']->getValue());
Assert::assertEquals($campaign_notification_email_addresses, $form['config[notification_config][campaign_notification_email_addresses]']->getValue());
Assert::assertEquals($send_notification_to_author, $form['config[notification_config][webhook_send_notification_to_author]']->getValue());
Assert::assertEquals($webhook_notification_email_addresses, $form['config[notification_config][webhook_notification_email_addresses]']->getValue());
}
public function testUserAndSystemLocale(): void
{
// 1. Change user locale in account - should change _locale session
$accountCrawler = $this->client->request(Request::METHOD_GET, '/s/account');
$this->assertResponseIsSuccessful();
$accountSaveButton = $accountCrawler->selectButton('user[buttons][save]');
$accountForm = $accountSaveButton->form();
$accountForm->setValues(
[
'user[locale]' => 'en_US',
]
);
$this->client->submit($accountForm);
$this->assertResponseIsSuccessful();
Assert::assertSame('en_US', $this->client->getRequest()->getSession()->get('_locale'));
// 2. Change system locale in configuration - should not change _locale session
$configCrawler = $this->client->request(Request::METHOD_GET, '/s/config/edit');
$configSaveButton = $configCrawler->selectButton('config[buttons][save]');
$configForm = $configSaveButton->form();
$configForm->setValues(
[
'config[coreconfig][locale]' => 'en_US',
'config[coreconfig][site_url]' => 'https://mautic-cloud.local', // required
]
);
$this->client->submit($configForm);
$this->assertResponseIsSuccessful();
Assert::assertSame('en_US', $this->client->getRequest()->getSession()->get('_locale'));
// 3. Change user locale to system default in account - should change _locale session to system default
$accountCrawler = $this->client->request(Request::METHOD_GET, '/s/account');
$accountSaveButton = $accountCrawler->selectButton('user[buttons][save]');
$accountForm = $accountSaveButton->form();
$accountForm->setValues(
[
'user[locale]' => '',
]
);
$this->client->submit($accountForm);
$this->assertResponseIsSuccessful();
Assert::assertSame('en_US', $this->client->getRequest()->getSession()->get('_locale'));
// 2. Change system locale in configuration to en_US - should change _locale session
$configCrawler = $this->client->request(Request::METHOD_GET, '/s/config/edit');
$configSaveButton = $configCrawler->selectButton('config[buttons][save]');
$configForm = $configSaveButton->form();
$configForm->setValues(
[
'config[coreconfig][locale]' => 'en_US',
'config[coreconfig][site_url]' => 'https://mautic-cloud.local', // required
]
);
$this->client->submit($configForm);
$this->assertResponseIsSuccessful();
Assert::assertSame('en_US', $this->client->getRequest()->getSession()->get('_locale'));
}
public function testSSOSettingEntityId(): void
{
$configCrawler = $this->client->request(Request::METHOD_GET, '/s/config/edit');
$configSaveButton = $configCrawler->selectButton('config[buttons][apply]');
$configForm = $configSaveButton->form();
/** @var ChoiceFormField $entityIdField */
$entityIdField = $configForm['config[userconfig][saml_idp_entity_id]'];
$availableOptions = $entityIdField->availableOptionValues();
Assert::assertCount(3, $availableOptions);
$configForm->setValues(
[
'config[userconfig][saml_idp_entity_id]' => $availableOptions[1],
'config[coreconfig][site_url]' => 'https://mautic-cloud.local', // required
]
);
$this->client->submit($configForm);
$this->assertResponseIsSuccessful();
Assert::assertEquals($availableOptions[1], $configForm['config[userconfig][saml_idp_entity_id]']->getValue());
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Mautic\ConfigBundle\Tests\Controller;
use Mautic\ConfigBundle\Model\SysinfoModel;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
class SysinfoControllerTest extends MauticMysqlTestCase
{
public function testDbInfoIsShown(): void
{
$sysinfoModel = static::getContainer()->get(SysinfoModel::class);
\assert($sysinfoModel instanceof SysinfoModel);
$dbInfo = $sysinfoModel->getDbInfo();
// Request sysinfo page
$crawler = $this->client->request(Request::METHOD_GET, '/s/sysinfo');
Assert::assertTrue($this->client->getResponse()->isOk());
$dbVersion = $crawler->filterXPath("//td[@id='dbinfo-version']")->text();
$dbDriver = $crawler->filterXPath("//td[@id='dbinfo-driver']")->text();
$dbPlatform = $crawler->filterXPath("//td[@id='dbinfo-platform']")->text();
$recommendations = $crawler->filter('#recommendations');
Assert::assertSame($dbInfo['version'], $dbVersion);
Assert::assertSame($dbInfo['driver'], $dbDriver);
Assert::assertSame($dbInfo['platform'], $dbPlatform);
Assert::assertGreaterThan(0, $recommendations->count());
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Mautic\ConfigBundle\Tests\Event;
use Mautic\ConfigBundle\Event\ConfigBuilderEvent;
use Mautic\CoreBundle\Tests\CommonMocks;
class ConfigBuilderEventTest extends CommonMocks
{
public function testAddForm(): void
{
$event = $this->initEvent();
$form = ['formAlias' => 'testform'];
$result = $event->addForm($form);
$this->assertTrue($result instanceof ConfigBuilderEvent);
$forms = $event->getForms();
$this->assertEquals($form, $forms[$form['formAlias']]);
}
public function testRemoveForm(): void
{
$event = $this->initEvent();
$form = ['formAlias' => 'testform'];
$event->addForm($form);
$result = $event->removeForm($form['formAlias']);
$forms = $event->getForms();
$this->assertEquals([], $forms);
$this->assertTrue($result);
}
protected function initEvent()
{
return new ConfigBuilderEvent($this->getBundleHelperMock());
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Mautic\ConfigBundle\Tests\Event;
use Mautic\ConfigBundle\Event\ConfigEvent;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\ParameterBag;
class ConfigEventTest extends \PHPUnit\Framework\TestCase
{
public function testGetSetConfig(): void
{
// Config not defined
$config = [];
$paramBag = $this->createMock(ParameterBag::class);
$event = new ConfigEvent($config, $paramBag);
$key = 'undefined';
$this->assertEquals([], $event->getConfig($key));
// Config defined with setter
$key = 'defined';
$config = ['config' => []];
$event->setConfig($config, $key);
$this->assertEquals($config, $event->getConfig($key));
// Config not found by key so complete config returned;
$undefinedKey = 'undefined';
$this->assertEquals([], $event->getConfig($undefinedKey));
// Get complete config
$config = [$key => $config];
$this->assertEquals($config, $event->getConfig());
}
public function testGetSetPreserved(): void
{
$config = [];
$paramBag = $this->createMock(ParameterBag::class);
$event = new ConfigEvent($config, $paramBag);
$this->assertEquals([], $event->getPreservedFields());
$preserved = 'preserved';
$result = [$preserved];
$event->unsetIfEmpty($preserved);
$this->assertEquals($result, $event->getPreservedFields());
$preserved = ['preserved' => 'value'];
$result = array_merge($result, $preserved);
$event->unsetIfEmpty($preserved);
$this->assertEquals($result, $event->getPreservedFields());
}
public function testGetSetErrors(): void
{
$config = [];
$paramBag = $this->createMock(ParameterBag::class);
$event = new ConfigEvent($config, $paramBag);
$this->assertEquals([], $event->getErrors());
$message = 'message';
$messages = [$message => []];
$this->assertEquals($event, $event->setError($message));
$this->assertEquals($messages, $event->getErrors());
$message = 'message';
$messageVars = ['var' => 'value'];
$messages = [$message => $messageVars];
$this->assertEquals($event, $event->setError($message, $messageVars));
$this->assertEquals($messages, $event->getErrors());
$message = 'message';
$messageVars = ['var' => 'value'];
$key = 'key';
$field = 'field';
$fieldErrors[$key][$field] = [
$message,
$messageVars,
];
$this->assertEquals($event, $event->setError($message, $messageVars, $key, $field));
$this->assertEquals($fieldErrors, $event->getFieldErrors());
}
public function testGetFileContent(): void
{
$config = [];
$paramBag = $this->createMock(ParameterBag::class);
$event = new ConfigEvent($config, $paramBag);
$fileContent = 'content';
$fileHandler = tmpfile();
$realPath = stream_get_meta_data($fileHandler)['uri'];
fwrite($fileHandler, ' '.$fileContent);
$uploadedFile = $this->createMock(UploadedFile::class);
$uploadedFile->expects($this->once())
->method('getRealPath')
->willReturn($realPath);
$this->assertEquals($fileContent, $event->getFileContent($uploadedFile));
$this->assertFalse(file_exists($realPath));
}
public function testEncodeFileContents(): void
{
$config = [];
$paramBag = $this->createMock(ParameterBag::class);
$event = new ConfigEvent($config, $paramBag);
$string = 'řčžýřžýčř';
$result = 'xZnEjcW+w73FmcW+w73EjcWZ';
$this->assertEquals($result, $event->encodeFileContents($string));
}
public function testNormalizedDataGetSet(): void
{
$config = [];
$paramBag = $this->createMock(ParameterBag::class);
$event = new ConfigEvent($config, $paramBag);
$origNormData = ['orig'];
$this->assertInstanceOf(ConfigEvent::class, $event->setOriginalNormData($origNormData));
$this->assertEquals($origNormData, $event->getOriginalNormData());
$normData = ['norm'];
$event->setNormData($normData);
$this->assertEquals($normData, $event->getNormData());
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Mautic\ConfigBundle\Tests\EventListener;
use Mautic\ConfigBundle\ConfigEvents;
use Mautic\ConfigBundle\Event\ConfigEvent;
use Mautic\ConfigBundle\EventListener\ConfigSubscriber;
use Mautic\ConfigBundle\Service\ConfigChangeLogger;
use Mautic\CoreBundle\Entity\AuditLogRepository;
use Mautic\CoreBundle\Entity\IpAddressRepository;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ConfigSubscriberTest extends TestCase
{
/**
* @var ConfigChangeLogger|MockObject
*/
private MockObject $logger;
private ConfigSubscriber $subscriber;
protected function setUp(): void
{
$this->logger = $this->createMock(ConfigChangeLogger::class);
$ipAddressRepo = $this->createMock(IpAddressRepository::class);
$coreParamHelper = $this->createMock(CoreParametersHelper::class);
$auditLogRepo = $this->createMock(AuditLogRepository::class);
$this->subscriber = new ConfigSubscriber($this->logger, $ipAddressRepo, $coreParamHelper, $auditLogRepo);
}
public function testGetSubscribedEvents(): void
{
$this->assertEquals(
[
ConfigEvents::CONFIG_POST_SAVE => ['onConfigPostSave', 0],
],
$this->subscriber->getSubscribedEvents()
);
}
public function testNothingToLogOnConfigPostSave(): void
{
// Test nothing to log
$this->logger->expects($this->never())
->method('log');
$event = $this->createMock(ConfigEvent::class);
$event->expects($this->once())
->method('getOriginalNormData')
->willReturn(null);
$this->subscriber->onConfigPostSave($event);
}
public function testSomethingToLogOnConfigPostSave(): void
{
// Test something to log
$originalNormData = ['orig'];
$normData = ['norm'];
$event = $this->createMock(ConfigEvent::class);
$event->expects($this->once())
->method('getOriginalNormData')
->willReturn($originalNormData);
$event->expects($this->once())
->method('getNormData')
->willReturn($normData);
$this->logger->expects($this->once())
->method('setOriginalNormData')
->with($originalNormData)
->willReturn($this->logger);
$this->logger->expects($this->once())
->method('log')
->with($normData);
$this->subscriber->onConfigPostSave($event);
}
}

View File

@@ -0,0 +1,257 @@
<?php
namespace Mautic\ConfigBundle\Tests\Form\Helper;
use Mautic\ConfigBundle\Form\DataTransformer\DsnTransformerFactory;
use Mautic\ConfigBundle\Form\Helper\RestrictionHelper;
use Mautic\ConfigBundle\Form\Type\ConfigType;
use Mautic\ConfigBundle\Form\Type\DsnType;
use Mautic\ConfigBundle\Form\Type\EscapeTransformer;
use Mautic\CoreBundle\Form\Type\ButtonGroupType;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\StandAloneButtonType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\EventListener\ProcessBounceSubscriber;
use Mautic\EmailBundle\EventListener\ProcessUnsubscribeSubscriber;
use Mautic\EmailBundle\Form\Type\ConfigMonitoredEmailType;
use Mautic\EmailBundle\Form\Type\ConfigMonitoredMailboxesType;
use Mautic\EmailBundle\Form\Type\ConfigType as EmailConfigType;
use Mautic\EmailBundle\MonitoredEmail\Mailbox;
use Mautic\EmailBundle\MonitoredEmail\Processor\Bounce;
use Mautic\EmailBundle\MonitoredEmail\Processor\FeedbackLoop;
use Mautic\EmailBundle\MonitoredEmail\Processor\Unsubscribe;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\Forms;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Mocking a representative ConfigForm by leveraging Symfony's TypeTestCase to test RestrictionHelper.
*/
#[\PHPUnit\Framework\Attributes\CoversClass(RestrictionHelper::class)]
class RestrictionHelperTest extends TypeTestCase
{
/**
* @var string
*/
private $displayMode = RestrictionHelper::MODE_REMOVE;
/**
* @var array
*/
private $restrictedFields = [
'monitored_email' => [
'EmailBundle_bounces',
'EmailBundle_unsubscribes' => [
'address',
],
],
];
private $forms = [
'emailconfig' => [
'bundle' => 'EmailBundle',
'formAlias' => 'emailconfig',
'formType' => EmailConfigType::class,
'formTheme' => 'MauticEmailBundle:FormTheme\\Config',
'parameters' => [
'mailer_from_name' => 'Mautic',
'mailer_from_email' => 'email@yoursite.com',
'mailer_return_path' => null,
'mailer_transport' => 'mail',
'mailer_append_tracking_pixel' => true,
'mailer_convert_embed_images' => false,
'mailer_dsn' => 'smtp://null:25',
'messenger_dsn_email' => 'doctrine://default',
'messenger_retry_strategy_max_retries' => 3,
'messenger_retry_strategy_delay' => 1000,
'messenger_retry_strategy_multiplier' => 2,
'messenger_retry_strategy_max_delay' => 0,
'unsubscribe_text' => null,
'webview_text' => null,
'unsubscribe_message' => null,
'resubscribe_message' => null,
'monitored_email' => [
'general' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
],
'EmailBundle_bounces' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => null,
],
'EmailBundle_unsubscribes' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => null,
],
'EmailBundle_replies' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => null,
],
],
'mailer_is_owner' => false,
'default_signature_text' => null,
'email_frequency_number' => null,
'email_frequency_time' => null,
'show_contact_preferences' => false,
'show_contact_frequency' => false,
'show_contact_pause_dates' => false,
'show_contact_preferred_channels' => false,
'show_contact_categories' => false,
'show_contact_segments' => false,
'mailer_mailjet_sandbox' => false,
'mailer_mailjet_sandbox_default_mail' => null,
'disable_trackable_urls' => false,
],
],
];
#[\PHPUnit\Framework\Attributes\TestDox('Test that the restricted fields are removed from the config')]
public function testRestrictedFieldsAreRemoved(): void
{
$form = $this->factory->create(ConfigType::class, $this->forms);
$this->assertTrue($form->has('emailconfig'));
$emailConfig = $form->get('emailconfig');
// monitored_email is partially restricted so should be included
$this->assertTrue($emailConfig->has('monitored_email'));
$monitoredEmail = $emailConfig->get('monitored_email');
// EmailBundle_bounces is restricted in entirety and thus should not be included
$this->assertFalse($monitoredEmail->has('EmailBundle_bounces'));
// EmailBundle_unsubscribes is partially restricted so should be included
$this->assertTrue($monitoredEmail->has('EmailBundle_unsubscribes'));
$unsubscribes = $monitoredEmail->get('EmailBundle_unsubscribes');
// address under EmailBundle_unsubscribes is restricted so should not be included
$this->assertFalse($unsubscribes->has('address'));
// host under EmailBundle_unsubscribes is not restricted so should be included
$this->assertTrue($unsubscribes->has('host'));
}
#[\PHPUnit\Framework\Attributes\TestDox('Test that the restricted fields are masked')]
public function testRestrictedFieldsAreMasked(): void
{
$this->displayMode = RestrictionHelper::MODE_MASK;
// Rebuild factory to get updated RestrictionHelper
$this->factory = Forms::createFormFactoryBuilder()
->addExtensions($this->getExtensions())
->getFormFactory();
$form = $this->factory->create(ConfigType::class, $this->forms);
/** @var FormInterface<mixed> $address */
$address = $form['emailconfig']['monitored_email']['EmailBundle_unsubscribes']['address'];
$this->assertTrue($address->getConfig()->getOption('attr')['readonly']);
$this->assertTrue($address->getConfig()->getOption('disabled'));
$this->assertEquals(
[
'class' => 'form-control',
'tooltip' => 'mautic.email.config.monitored_email_address.tooltip',
'data-show-on' => '{"config_emailconfig_monitored_email_EmailBundle_unsubscribes_override_settings_1": "checked"}',
'placeholder' => 'mautic.config.restricted',
'readonly' => true,
],
$address->getConfig()->getOption('attr')
);
}
/**
* @return array
*/
protected function getExtensions()
{
$translator = $this->createMock(Translator::class);
$translator->method('trans')
->willReturnCallback(
fn ($key) => $key
);
$validator = $this->createMock(ValidatorInterface::class);
$validator
->method('validate')
->willReturn(new ConstraintViolationList());
$validator
->method('getMetadataFor')
->willReturn(new ClassMetadata(Form::class));
$imapHelper = $this->createMock(Mailbox::class);
// Register monitored email listeners
$dispatcher = new EventDispatcher();
$bouncer = $this->createMock(Bounce::class);
$dispatcher->addSubscriber(new ProcessBounceSubscriber($bouncer));
$unsubscriber = $this->createMock(Unsubscribe::class);
$looper = $this->createMock(FeedbackLoop::class);
$dispatcher->addSubscriber(new ProcessUnsubscribeSubscriber($unsubscriber, $looper));
// This is what we're really testing here
$restrictionHelper = new RestrictionHelper($translator, $this->restrictedFields, $this->displayMode);
$escapeTransformer = new EscapeTransformer([]);
return [
// register the type instances with the PreloadedExtension
new PreloadedExtension(
[
new TextType(),
new ChoiceType(),
new YesNoButtonGroupType(),
new PasswordType(),
new StandAloneButtonType(),
new NumberType(),
new FormButtonsType(),
new ButtonGroupType(),
new EmailConfigType($translator),
new DsnType($this->createMock(DsnTransformerFactory::class), $this->createMock(CoreParametersHelper::class)),
new ConfigMonitoredEmailType($dispatcher),
new ConfigMonitoredMailboxesType($imapHelper),
new ConfigType($restrictionHelper, $escapeTransformer),
],
[]
),
new ValidatorExtension($validator),
];
}
}

View File

@@ -0,0 +1,249 @@
<?php
namespace Mautic\ConfigBundle\Tests\Mapper;
use Mautic\ConfigBundle\Exception\BadFormConfigException;
use Mautic\ConfigBundle\Mapper\ConfigMapper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
#[\PHPUnit\Framework\Attributes\CoversClass(BadFormConfigException::class)]
#[\PHPUnit\Framework\Attributes\CoversClass(ConfigMapper::class)]
class ConfigMapperTest extends \PHPUnit\Framework\TestCase
{
private $forms = [
'emailconfig' => [
'bundle' => 'EmailBundle',
'formAlias' => 'emailconfig',
'formTheme' => 'MauticEmailBundle:FormTheme\\Config',
'parameters' => [
'mailer_from_name' => 'Mautic',
'mailer_from_email' => 'email@yoursite.com',
'mailer_return_path' => null,
'mailer_transport' => 'mail',
'mailer_append_tracking_pixel' => true,
'mailer_convert_embed_images' => false,
'mailer_dsn' => 'smtp://null:25',
'messenger_dsn_email' => 'doctrine://default',
'messenger_retry_strategy_max_retries' => 3,
'messenger_retry_strategy_delay' => 1000,
'messenger_retry_strategy_multiplier' => 2,
'messenger_retry_strategy_max_delay' => 0,
'unsubscribe_text' => null,
'webview_text' => null,
'unsubscribe_message' => null,
'resubscribe_message' => null,
'monitored_email' => [
'general' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
],
'EmailBundle_bounces' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => null,
],
'EmailBundle_unsubscribes' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => null,
],
'EmailBundle_replies' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => null,
],
],
'mailer_is_owner' => false,
'default_signature_text' => null,
'email_frequency_number' => null,
'email_frequency_time' => null,
'show_contact_preferences' => false,
'show_contact_frequency' => false,
'show_contact_pause_dates' => false,
'show_contact_preferred_channels' => false,
'show_contact_categories' => false,
'show_contact_segments' => false,
'mailer_mailjet_sandbox' => false,
'mailer_mailjet_sandbox_default_mail' => null,
'disable_trackable_urls' => false,
],
],
];
private $config = [
'db_host' => 'dbhost',
'db_user' => 'dbuser',
'monitored_email' => [
'general' => [
'address' => 'test@test.com',
'host' => 'test.com',
'port' => '143',
'encryption' => '/tls/novalidate-cert',
'user' => 'test@test.com',
'password' => 'password',
],
'EmailBundle_bounces' => [
'address' => 'test2@test.com',
'host' => 'test2.com',
'port' => '143',
'encryption' => '/tls/novalidate-cert',
'user' => 'test2@test.com',
'password' => 'password',
'override_settings' => 1,
'folder' => 'INBOX',
],
'EmailBundle_unsubscribes' => [
'address' => 'test3@test.com',
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => 'INBOX',
],
'EmailBundle_replies' => [
'address' => 'test4@test.com',
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => 'INBOX',
],
],
];
#[\PHPUnit\Framework\Attributes\TestDox('Exception should be thrown if parameters key is not found in a form config')]
public function testExceptionIsThrownOnBadFormConfig(): void
{
$this->expectException(BadFormConfigException::class);
$forms = [
'emailconfig' => [
'bundle' => 'EmailBundle',
'formAlias' => 'emailconfig',
'formTheme' => 'MauticEmailBundle:FormTheme\Config',
],
];
$parameterHelper = $this->createMock(CoreParametersHelper::class);
$mapper = new ConfigMapper($parameterHelper, []);
$mapper->bindFormConfigsWithRealValues($forms);
}
#[\PHPUnit\Framework\Attributes\TestDox('Defaults should be bound when local config has no values')]
public function testParametersAreBoundToDefaults(): void
{
$parameterHelper = $this->createMock(CoreParametersHelper::class);
$mapper = new ConfigMapper($parameterHelper, []);
$processedForms = $mapper->bindFormConfigsWithRealValues($this->forms);
$this->assertEquals($this->forms, $processedForms);
}
#[\PHPUnit\Framework\Attributes\TestDox('Defaults should be merged with local config values')]
public function testParametersAreBoundToDefaultsWithLocalConfig(): void
{
$parameterHelper = $this->createMock(CoreParametersHelper::class);
$parameterHelper->method('get')
->willReturnCallback(
fn ($param, $defaultValue) => array_key_exists($param, $this->config) ? $this->config[$param] : $defaultValue
);
$mapper = new ConfigMapper($parameterHelper, []);
$forms = $this->forms;
$processedForms = $mapper->bindFormConfigsWithRealValues($forms);
// Update expected
$forms['emailconfig']['parameters']['monitored_email'] = [
'general' => [
'address' => 'test@test.com',
'host' => 'test.com',
'port' => '143',
'encryption' => '/tls/novalidate-cert',
'user' => 'test@test.com',
'password' => 'password',
],
'EmailBundle_bounces' => [
'address' => 'test2@test.com',
'host' => 'test2.com',
'port' => '143',
'encryption' => '/tls/novalidate-cert',
'user' => 'test2@test.com',
'password' => 'password',
'override_settings' => 1,
'folder' => 'INBOX',
],
'EmailBundle_unsubscribes' => [
'address' => 'test3@test.com',
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => 'INBOX',
],
'EmailBundle_replies' => [
'address' => 'test4@test.com',
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => 'INBOX',
],
];
$this->assertEquals($forms, $processedForms);
}
#[\PHPUnit\Framework\Attributes\TestDox('Defaults should be merged with local config values but restricted fields should be removed')]
public function testParametersAreBoundToDefaultsWithLocalConfigAndRestrictionsAppied(): void
{
$parameterHelper = $this->createMock(CoreParametersHelper::class);
$parameterHelper->method('get')
->willReturnCallback(
fn ($param, $defaultValue) => array_key_exists($param, $this->config) ? $this->config[$param] : $defaultValue
);
$mapper = new ConfigMapper($parameterHelper, ['monitored_email']);
$forms = $this->forms;
$processedForms = $mapper->bindFormConfigsWithRealValues($forms);
// Expected should have had monitored_email unset due to it being restricted
unset($forms['emailconfig']['parameters']['monitored_email']);
$this->assertEquals($forms, $processedForms);
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Mautic\ConfigBundle\Tests\Mapper\Helper;
use Mautic\ConfigBundle\Mapper\Helper\ConfigHelper;
#[\PHPUnit\Framework\Attributes\CoversClass(ConfigHelper::class)]
class ConfigHelperTest extends \PHPUnit\Framework\TestCase
{
#[\PHPUnit\Framework\Attributes\TestDox('Ensure a mixed numeric/string keyed array is formatted to all string based keys')]
public function testNestedLocalParametersAreBoundCorrectly(): void
{
$defaults = [
'db_host' => null,
'db_user' => null,
'api_enabled' => 1,
'monitored_email' => [
'general' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
],
'EmailBundle_bounces' => [
'address' => 'test2@test.com',
'host' => 'test2.com',
'port' => '143',
'encryption' => '/tls/novalidate-cert',
'user' => 'test2@test.com',
'password' => 'password',
'override_settings' => 1,
'folder' => 'INBOX',
],
'EmailBundle_unsubscribes' => [
'address' => 'test3@test.com',
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => 'INBOX',
],
],
];
$config = [
'db_host' => 'dbhost',
'db_user' => 'dbuser',
'monitored_email' => [
'general' => [
'address' => 'test@test.com',
'host' => 'test.com',
'port' => '143',
'encryption' => '/tls/novalidate-cert',
'user' => 'test@test.com',
'password' => 'password',
],
'EmailBundle_bounces' => null,
'EmailBundle_unsubscribes' => [
'address' => 'test3@test.com',
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => 'INBOX',
],
'EmailBundle_replies' => [
'address' => 'test4@test.com',
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => 'INBOX',
],
],
];
$expected = [
// from config
'db_host' => 'dbhost',
'db_user' => 'dbuser',
// from defaults
'api_enabled' => 1,
'monitored_email' => [
// from config
'general' => [
'address' => 'test@test.com',
'host' => 'test.com',
'port' => '143',
'encryption' => '/tls/novalidate-cert',
'user' => 'test@test.com',
'password' => 'password',
],
'EmailBundle_bounces' => [
// from defaults
'address' => 'test2@test.com',
'host' => 'test2.com',
'port' => '143',
'encryption' => '/tls/novalidate-cert',
'user' => 'test2@test.com',
'password' => 'password',
'override_settings' => 1,
'folder' => 'INBOX',
],
// from config
'EmailBundle_unsubscribes' => [
'address' => 'test3@test.com',
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => 'INBOX',
],
// from config
'EmailBundle_replies' => [
'address' => 'test4@test.com',
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => 'INBOX',
],
],
];
$this->assertEquals($expected, ConfigHelper::bindNestedConfigValues($config, $defaults));
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Mautic\ConfigBundle\Tests\Mapper\Helper;
use Mautic\ConfigBundle\Mapper\Helper\RestrictionHelper;
#[\PHPUnit\Framework\Attributes\CoversClass(RestrictionHelper::class)]
class RestrictionHelperTest extends \PHPUnit\Framework\TestCase
{
/**
* @var array
*/
private $restrictedFields = [
'db_host',
'db_user',
'monitored_email' => [
'EmailBundle_bounces',
'EmailBundle_unsubscribes' => [
'address',
],
],
];
#[\PHPUnit\Framework\Attributes\TestDox('Ensure a mixed numeric/string keyed array is formatted to all string based keys')]
public function testRestrictedConfigArrayIsFormattedCorrectly(): void
{
$expected = [
'db_host' => 'db_host',
'db_user' => 'db_user',
'monitored_email' => [
'EmailBundle_bounces' => 'EmailBundle_bounces',
'EmailBundle_unsubscribes' => [
'address' => 'address',
],
],
];
$this->assertEquals($expected, RestrictionHelper::prepareRestrictions($this->restrictedFields));
}
#[\PHPUnit\Framework\Attributes\TestDox('Ensure a restrictions are recursively applied')]
public function testApplyingRestrictionsToConfigArray(): void
{
$config = [
'db_host' => 'dbhost',
'db_user' => 'dbuser',
'api_enabled' => 1,
'monitored_email' => [
'general' => [
'address' => 'test@test.com',
'host' => 'test.com',
'port' => '143',
'encryption' => '/tls/novalidate-cert',
'user' => 'test@test.com',
'password' => 'password',
],
'EmailBundle_bounces' => [
'address' => '',
'host' => '',
'port' => '993',
'encryption' => '/ssl',
'user' => '',
'password' => '',
'override_settings' => 0,
'folder' => 'INBOX',
],
'EmailBundle_unsubscribes' => [
'address' => null,
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => 'INBOX',
],
],
];
$expected = [
'api_enabled' => 1,
'monitored_email' => [
'general' => [
'address' => 'test@test.com',
'host' => 'test.com',
'port' => '143',
'encryption' => '/tls/novalidate-cert',
'user' => 'test@test.com',
'password' => 'password',
],
'EmailBundle_unsubscribes' => [
'host' => null,
'port' => '993',
'encryption' => '/ssl',
'user' => null,
'password' => null,
'override_settings' => 0,
'folder' => 'INBOX',
],
],
];
$restrictedFields = RestrictionHelper::prepareRestrictions($this->restrictedFields);
$this->assertEquals($expected, RestrictionHelper::applyRestrictions($config, $restrictedFields));
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Mautic\ConfigBundle\Tests\Service;
use Mautic\ConfigBundle\Service\ConfigChangeLogger;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
class ConfigChangeLoggerTest extends \PHPUnit\Framework\TestCase
{
public function testSetOriginalNormData(): void
{
$ipLookupHelper = $this->createMock(IpLookupHelper::class);
$auditLogModel = $this->createMock(AuditLogModel::class);
$logger = new ConfigChangeLogger($ipLookupHelper, $auditLogModel);
$this->assertEquals($logger, $logger->setOriginalNormData([]));
}
public function testOriginalNormDataExpected(): void
{
$this->expectException(\RuntimeException::class);
$ipLookupHelper = $this->createMock(IpLookupHelper::class);
$ipLookupHelper->expects($this->never())->method('getIpAddressFromRequest');
$auditLogModel = $this->createMock(AuditLogModel::class);
$auditLogModel->expects($this->never())->method('writeToLog');
$logger = new ConfigChangeLogger($ipLookupHelper, $auditLogModel);
$logger->log([]);
}
public function testNothingToLog(): void
{
$ipLookupHelper = $this->createMock(IpLookupHelper::class);
$ipLookupHelper->expects($this->never())->method('getIpAddressFromRequest');
$auditLogModel = $this->createMock(AuditLogModel::class);
$auditLogModel->expects($this->never())->method('writeToLog');
$logger = new ConfigChangeLogger($ipLookupHelper, $auditLogModel);
$originalData = $postData = [
'bundle' => [
'key' => 'value',
],
];
$this->assertEquals($logger, $logger->setOriginalNormData($originalData));
$logger->log($postData);
}
public function testLog(): void
{
$change = [
'key2' => 'changedValue',
];
$filterMe = [
'transifex_password' => 'dhjsakjfda',
'mailer_is_owner' => 'lksajhd',
];
$log = [
'bundle' => 'config',
'object' => 'config',
'objectId' => 0,
'action' => 'update',
'details' => $change,
'ipAddress' => null,
];
$ipLookupHelper = $this->createMock(IpLookupHelper::class);
$ipLookupHelper->expects($this->once())->method('getIpAddressFromRequest');
$auditLogModel = $this->createMock(AuditLogModel::class);
$auditLogModel->expects($this->once())->method('writeToLog')->with($log);
$logger = new ConfigChangeLogger($ipLookupHelper, $auditLogModel);
$originalData = [
'bundle' => [
'key' => 'value',
],
'bundle2' => [
'parameters' => [
'key2' => 'value2',
],
],
];
$postData = [
'bundle' => [
'key' => 'value',
],
'bundle2' => array_merge($change, $filterMe),
];
$this->assertEquals($logger, $logger->setOriginalNormData($originalData));
$logger->log($postData);
}
}

View File

@@ -0,0 +1,2 @@
mautic.config.config.error.not.updated="Could not save updated configuration: %exception%"
mautic.config.config.notice.updated="Configuration successfully updated"

View File

@@ -0,0 +1,37 @@
mautic.config.header.index="Configuration"
mautic.config.menu.index="Configuration"
mautic.config.restricted="Set by system"
mautic.config.notwritable="The configuration file is not writable! Changes will not be saved."
mautic.config.remove_file_contents="Remove stored contents for this setting?"
mautic.sysinfo.header.index="System Info"
mautic.sysinfo.menu.index="System Info"
mautic.sysinfo.tab.phpinfo="PHP Info"
mautic.sysinfo.tab.recommendations="Recommendations"
mautic.sysinfo.no.recommendations="There are no recommendations for you right now. Your server is configured properly!"
mautic.sysinfo.tab.folders="Folder & File Permissions"
mautic.sysinfo.folders.title="The following folders and files must be writable for Mautic to work correctly."
mautic.sysinfo.folder.path="Folder/File Path"
mautic.sysinfo.is.writable="Is writable"
mautic.sysinfo.writable="Writable"
mautic.sysinfo.unwritable="Unwritable"
mautic.sysinfo.tab.log="Log"
mautic.sysinfo.log.title="Current Error Log"
mautic.sysinfo.log.missing="Today's Mautic error log is empty. Check server error log for error messages Mautic didn't have a chance to catch."
mautic.sysinfo.phpinfo.missing="Information is not available. PHP function phpinfo() is disabled on your server."
mautic.sysinfo.phpinfo.phpversion="PHP function phpinfo() is disabled on your server. Your PHP version is <b>%phpversion%</b>."
mautic.sysinfo.tab.dbinfo="Database info"
mautic.sysinfo.dbinfo.title="Database info"
mautic.sysinfo.dbinfo.property="Property"
mautic.sysinfo.dbinfo.value="Value"
mautic.sysinfo.dbinfo.version="Version"
mautic.sysinfo.dbinfo.driver="Doctrine driver"
mautic.sysinfo.dbinfo.platform="Doctrine database platform (automatically detected)"
mautic.config.dsn.scheme="Scheme"
mautic.config.dsn.host="Host"
mautic.config.dsn.port="Port"
mautic.config.dsn.user="User"
mautic.config.dsn.password="Password"
mautic.config.dsn.path="Path"
mautic.config.dsn.options="Options"
mautic.config.dsn.using_current_dsn="Using currently saved DSN"
mautic.config.dsn.save_to_test="Save changes to test the DSN."