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,78 @@
<?php
namespace Mautic\ApiBundle;
final class ApiEvents
{
/**
* The mautic.client_pre_save event is thrown right before an API client is persisted.
*
* The event listener receives a Mautic\ApiBundle\Event\ClientEvent instance.
*
* @var string
*/
public const CLIENT_PRE_SAVE = 'mautic.client_pre_save';
/**
* The mautic.client_post_save event is thrown right after an API client is persisted.
*
* The event listener receives a Mautic\ApiBundle\Event\ClientEvent instance.
*
* @var string
*/
public const CLIENT_POST_SAVE = 'mautic.client_post_save';
/**
* The mautic.client_post_delete event is thrown after an API client is deleted.
*
* The event listener receives a Mautic\ApiBundle\Event\ClientEvent instance.
*
* @var string
*/
public const CLIENT_POST_DELETE = 'mautic.client_post_delete';
/**
* The mautic.build_api_route event is thrown to build Mautic API routes.
*
* The event listener receives a Mautic\CoreBundle\Event\RouteEvent instance.
*
* @var string
*/
public const BUILD_ROUTE = 'mautic.build_api_route';
/**
* The mautic.api_on_entity_pre_save event is thrown after an entity about to be saved via API.
*
* The event listener receives a Mautic\ApiBundle\Event\ApiEntityEvent instance.
*
* @var string
*/
public const API_ON_ENTITY_PRE_SAVE = 'mautic.api_on_entity_pre_save';
/**
* The mautic.api_on_entity_post_save event is thrown after an entity is saved via API.
*
* The event listener receives a Mautic\ApiBundle\Event\ApiEntityEvent instance.
*
* @var string
*/
public const API_ON_ENTITY_POST_SAVE = 'mautic.api_on_entity_post_save';
/**
* The mautic.api_pre_serialization_context event is dispatched before the serialization context is created for the view.
*
* The event listener receives a Mautic\ApiBundle\Event\ApiSerializationContextEvent instance.
*
* @var string
*/
public const API_PRE_SERIALIZATION_CONTEXT = 'mautic.api_pre_serialization_context';
/**
* The mautic.api_post_serialization_context event is dispatched after the serialization context is created for the view.
*
* The event listener receives a Mautic\ApiBundle\Event\ApiSerializationContextEvent instance.
*
* @var string
*/
public const API_POST_SERIALIZATION_CONTEXT = 'mautic.api_post_serialization_context';
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\ApiPlatform\EventListener;
use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\Util\RequestAttributesExtractor;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
final class MauticDenyAccessListener
{
public function __construct(
private CorePermissions $security,
private ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory)
{
}
public function onSecurity(RequestEvent $event): void
{
$this->checkSecurity($event->getRequest());
}
/**
* @throws ResourceClassNotFoundException
*/
private function checkSecurity(Request $request): void
{
if (!$attributes = RequestAttributesExtractor::extractAttributes($request)) {
return;
}
$resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']);
$operation = $resourceMetadata->getOperation($attributes['operation_name']);
$isGranted = $operation->getSecurity() ?? null;
if (null === $isGranted) {
return;
}
// Extract object path to getCreatedBy - () parenthesis at the end
preg_match('#\((.*?)\)#', $isGranted, $match);
$objectProperty = null;
if (count($match) > 1) {
$objectProperty = $match[1];
}
if ($startParenthesis = strpos($isGranted, '(')) {
$isGranted = substr($isGranted, 0, $startParenthesis);
}
// Extract id from object - [] parenthesis in the text
preg_match('#\[(.*?)\]#', $isGranted, $match);
$objectIdProperty = null;
if (count($match) > 1) {
$objectIdProperty = $match[1];
}
if (str_contains($isGranted, '[') && str_contains($isGranted, ']')) {
$startParenthesis = strpos($isGranted, '[');
$stopParenthesis = strpos($isGranted, ']');
if ($request->getContent()
&& ($contentArray = json_decode($request->getContent(), true))
&& is_array($contentArray)
&& array_key_exists($objectIdProperty, $contentArray)
) {
$url = $contentArray[$objectIdProperty];
$objectId = substr($url, strrpos($url, '/') + 1);
} else {
$requestObject = $request->attributes->get('data');
$property = 'get'.$objectIdProperty;
$objectId = $requestObject->$property()->getId();
}
$isGranted = substr($isGranted, 0, $startParenthesis).$objectId.substr($isGranted, $stopParenthesis + 1);
}
// Get the object to check the security
$requestObject = $request->attributes->get('data');
if (null !== $objectProperty) {
$objectPropertyList = explode('.', $objectProperty);
foreach ($objectPropertyList as $property) {
$requestObject = $requestObject->$property();
}
}
// Extract isGranted and action
$isGranted = str_replace('"', '', $isGranted);
$isGranted = str_replace("'", '', $isGranted);
$isGrantedList = explode(':', $isGranted);
$action = array_pop($isGrantedList);
if (in_array($action, ['view', 'edit', 'delete'])) {
if (!$this->security->hasEntityAccess($isGranted.'own', $isGranted.'other', $requestObject->getCreatedBy())) {
throw new AccessDeniedException();
}
} else {
if (!$this->security->isGranted($isGranted)) {
throw new AccessDeniedException();
}
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\ApiPlatform\EventListener;
use ApiPlatform\Symfony\EventListener\EventPriorities;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class MauticWriteSubscriber implements EventSubscriberInterface
{
public function __construct(private UserHelper $userHelper)
{
}
public static function getSubscribedEvents()
{
return [
KernelEvents::VIEW => ['addData', EventPriorities::PRE_WRITE],
];
}
public function addData(ViewEvent $event): void
{
$entity = $event->getControllerResult();
$method = $event->getRequest()->getMethod();
if (!$entity instanceof FormEntity
|| (
Request::METHOD_POST !== $method
&& Request::METHOD_PATCH !== $method
&& Request::METHOD_PUT !== $method
)
) {
return;
}
$user = $this->userHelper->getUser();
$now = new DateTimeHelper();
if ($entity->isNew()) {
$entity->setDateAdded($now->getUtcDateTime());
if ($user) {
$entity->setCreatedBy($user);
$entity->setCreatedByUser($user->getName());
}
}
$entity->setDateModified($now->getUtcDateTime());
if ($user) {
$entity->setModifiedBy($user);
$entity->setModifiedByUser($user->getName());
}
}
}

View File

@@ -0,0 +1,3 @@
.col-client-id{
width: 75px;
}

View File

@@ -0,0 +1,19 @@
//ApiBundle
Mautic.clientOnLoad = function (container) {
if (mQuery(container + ' #list-search').length) {
Mautic.activateSearchAutocomplete('list-search', 'api.client');
}
};
Mautic.refreshApiClientForm = function(url, modeEl) {
var mode = mQuery(modeEl).val();
if (mQuery('#client_redirectUris').length) {
mQuery('#client_redirectUris').prop('disabled', true);
} else {
mQuery('#client_callback').prop('disabled', true);
}
mQuery('#client_name').prop('disabled', true);
Mautic.loadContent(url + '/' + mode);
};

View File

@@ -0,0 +1,110 @@
<?php
return [
'routes' => [
'public' => [
// OAuth2
'fos_oauth_server_token' => [
'path' => '/oauth/v2/token',
'controller' => 'fos_oauth_server.controller.token::tokenAction',
'method' => 'GET|POST',
],
'fos_oauth_server_authorize' => [
'path' => '/oauth/v2/authorize',
'controller' => 'Mautic\ApiBundle\Controller\oAuth2\AuthorizeController::authorizeAction',
'method' => 'GET|POST',
],
'mautic_oauth2_server_auth_login' => [
'path' => '/oauth/v2/authorize_login',
'controller' => 'Mautic\ApiBundle\Controller\oAuth2\SecurityController::loginAction',
'method' => 'GET|POST',
],
'mautic_oauth2_server_auth_login_check' => [
'path' => '/oauth/v2/authorize_login_check',
'controller' => 'Mautic\ApiBundle\Controller\oAuth2\SecurityController::loginCheckAction',
'method' => 'GET|POST',
],
],
'main' => [
// Clients
'mautic_client_index' => [
'path' => '/credentials/{page}',
'controller' => 'Mautic\ApiBundle\Controller\ClientController::indexAction',
],
'mautic_client_action' => [
'path' => '/credentials/{objectAction}/{objectId}',
'controller' => 'Mautic\ApiBundle\Controller\ClientController::executeAction',
],
],
],
'menu' => [
'admin' => [
'items' => [
'mautic.api.client.menu.index' => [
'route' => 'mautic_client_index',
'access' => 'api:clients:view',
'parent' => 'mautic.core.integrations',
'iconClass' => 'ri-terminal-box-line',
'priority' => 17,
'checks' => [
'parameters' => [
'api_enabled' => true,
],
],
],
],
],
],
'services' => [
'helpers' => [
'mautic.api.helper.entity_result' => [
'class' => Mautic\ApiBundle\Helper\EntityResultHelper::class,
],
],
'other' => [
'mautic.api.oauth.event_listener' => [
'class' => Mautic\ApiBundle\EventListener\PreAuthorizationEventListener::class,
'arguments' => [
'doctrine.orm.entity_manager',
'mautic.security',
'translator',
],
'tags' => [
'kernel.event_listener',
'kernel.event_listener',
],
'tagArguments' => [
[
'event' => 'fos_oauth_server.pre_authorization_process',
'method' => 'onPreAuthorizationProcess',
],
[
'event' => 'fos_oauth_server.post_authorization_process',
'method' => 'onPostAuthorizationProcess',
],
],
],
'mautic.validator.oauthcallback' => [
'class' => Mautic\ApiBundle\Form\Validator\Constraints\OAuthCallbackValidator::class,
'tag' => 'validator.constraint_validator',
],
'mautic.api.security.voter.permission' => [
'class' => Mautic\ApiBundle\Security\Voter\ApiPermissionVoter::class,
'arguments' => [
'mautic.security',
],
'tag' => 'security.voter',
],
],
],
'parameters' => [
'api_enabled' => false,
'api_enable_basic_auth' => false,
'api_oauth2_access_token_lifetime' => 60,
'api_oauth2_refresh_token_lifetime' => 14,
'api_batch_max_limit' => 200,
],
];

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use FOS\OAuthServerBundle\Form\Handler\AuthorizeFormHandler;
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
'Serializer/Exclusion',
'Helper/BatchIdToEntityHelper.php',
];
$services->load('Mautic\\ApiBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('Mautic\\ApiBundle\\Entity\\oAuth2\\', '../Entity/oAuth2/*Repository.php');
$services->alias(AuthorizeFormHandler::class, 'fos_oauth_server.authorize.form.handler.default');
$services->get(Mautic\ApiBundle\Controller\oAuth2\AuthorizeController::class)
->arg('$authorizeForm', service('fos_oauth_server.authorize.form'))
->arg('$oAuth2Server', service('fos_oauth_server.server'))
->arg('$clientManager', service('fos_oauth_server.client_manager.default'))
->tag('controller.service_arguments');
$services->alias('mautic.api.model.client', Mautic\ApiBundle\Model\ClientModel::class);
// Register custom PUT processor to fix PUT operations globally
// This ensures PUT requests update existing entities instead of creating new ones
// This decorates the default persist processor so it applies to all entities automatically
$services->set(Mautic\ApiBundle\State\PutProcessor::class)
->decorate('api_platform.doctrine.orm.state.persist_processor')
->args([
service('.inner'),
service('doctrine.orm.entity_manager'),
]);
};

View File

@@ -0,0 +1,458 @@
<?php
namespace Mautic\ApiBundle\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Model\ClientModel;
use Mautic\CoreBundle\Controller\AbstractStandardFormController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Factory\PageHelperFactoryInterface;
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 Mautic\UserBundle\Entity\User;
use OAuth2\OAuth2;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class ClientController extends AbstractStandardFormController
{
public function __construct(
private ClientModel $clientModel,
FormFactoryInterface $formFactory,
FormFieldHelper $fieldHelper,
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);
}
/**
* Generate's default client list.
*
* @param int $page
*
* @return Response
*/
public function indexAction(Request $request, PageHelperFactoryInterface $pageHelperFactory, $page = 1)
{
if (!$this->security->isGranted('api:clients:view')) {
return $this->accessDenied();
}
$this->setListFilters();
$pageHelper= $pageHelperFactory->make('mautic.api.client', $page);
$limit = $pageHelper->getLimit();
$start = $pageHelper->getStart();
$orderBy = $request->getSession()->get('mautic.api.client.orderby', 'c.name');
$orderByDir= $request->getSession()->get('mautic.api.client.orderbydir', 'ASC');
$filter = $request->get('search', $request->getSession()->get('mautic.api.client.filter', ''));
$apiMode = $request->get('api_mode', $request->getSession()->get('mautic.api.client.filter.api_mode', 'oauth2'));
$request->getSession()->set('mautic.api.client.filter.api_mode', $apiMode);
$request->getSession()->set('mautic.api.client.filter', $filter);
$clients = $this->clientModel->getEntities(
[
'start' => $start,
'limit' => $limit,
'filter' => $filter,
'orderBy' => $orderBy,
'orderByDir' => $orderByDir,
]
);
$count = count($clients);
if ($count && $count < ($start + 1)) {
$lastPage = $pageHelper->countPage($count);
$returnUrl = $this->generateUrl('mautic_client_index', ['page' => $lastPage]);
$pageHelper->rememberPage($lastPage);
return $this->postActionRedirect(
[
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $lastPage],
'contentTemplate' => 'Mautic\ApiBundle\Controller\ClientController::indexAction',
'passthroughVars' => [
'activeLink' => 'mautic_client_index',
'mauticContent' => 'client',
],
]
);
}
$pageHelper->rememberPage($page);
// filters
$filters = [];
// api options
$apiOptions = [];
$apiOptions['oauth2'] = 'OAuth 2';
$filters['api_mode'] = [
'values' => [$apiMode],
'options' => $apiOptions,
];
return $this->delegateView(
[
'viewParameters' => [
'items' => $clients,
'page' => $page,
'limit' => $limit,
'permissions' => [
'create' => $this->security->isGranted('api:clients:create'),
'edit' => $this->security->isGranted('api:clients:editother'),
'delete' => $this->security->isGranted('api:clients:deleteother'),
],
'tmpl' => $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index',
'searchValue' => $filter,
'filters' => $filters,
],
'contentTemplate' => '@MauticApi/Client/list.html.twig',
'passthroughVars' => [
'route' => $this->generateUrl('mautic_client_index', ['page' => $page]),
'mauticContent' => 'client',
],
]
);
}
public function authorizedClientsAction(TokenStorageInterface $tokenStorage): Response
{
$me = $tokenStorage->getToken()->getUser();
\assert($me instanceof User);
$clients = $this->clientModel->getUserClients($me);
return $this->render('@MauticApi/Client/authorized.html.twig', ['clients' => $clients]);
}
/**
* @param int $clientId
*/
public function revokeAction(Request $request, $clientId): Response
{
$success = 0;
$flashes = [];
if ('POST' == $request->getMethod()) {
$client = $this->clientModel->getEntity($clientId);
if (null === $client) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.api.client.error.notfound',
'msgVars' => ['%id%' => $clientId],
];
} else {
$name = $client->getName();
$this->clientModel->revokeAccess($client);
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.api.client.notice.revoked',
'msgVars' => [
'%name%' => $name,
],
];
}
}
return $this->postActionRedirect(
[
'returnUrl' => $this->generateUrl('mautic_user_account'),
'contentTemplate' => 'Mautic\UserBundle\Controller\ProfileController::indexAction',
'passthroughVars' => [
'success' => $success,
],
'flashes' => $flashes,
]
);
}
/**
* @param mixed $objectId
*
* @return array|JsonResponse|RedirectResponse|Response
*/
public function newAction(Request $request, $objectId = 0)
{
if (!$this->security->isGranted('api:clients:create')) {
return $this->accessDenied();
}
$apiMode = (0 === $objectId) ? $request->getSession()->get('mautic.client.filter.api_mode', 'oauth2') : $objectId;
$request->getSession()->set('mautic.client.filter.api_mode', $apiMode);
$this->clientModel->setApiMode($apiMode);
// retrieve the entity
$client = $this->clientModel->getEntity();
// set the return URL for post actions
$returnUrl = $this->generateUrl('mautic_client_index');
// get the user form factory
$action = $this->generateUrl('mautic_client_action', ['objectAction' => 'new']);
$form = $this->clientModel->createForm($client, $this->formFactory, $action);
// remove the client id and secret fields as they'll be auto generated
$form->remove('randomId');
$form->remove('secret');
$form->remove('publicId');
$form->remove('consumerKey');
$form->remove('consumerSecret');
// /Check for a submitted form and process it
if ('POST' == $request->getMethod()) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
// form is valid so process the data
// If the admin is creating API credentials, enable 'Client Credential' grant type
/** @var User $user */
$user = $this->getUser();
if (ClientModel::API_MODE_OAUTH2 == $apiMode && $user->getRole()->isAdmin()) {
$client->addGrantType(OAuth2::GRANT_TYPE_CLIENT_CREDENTIALS);
}
$client->setRole($user->getRole());
$this->clientModel->saveEntity($client);
$this->addFlashMessage(
'mautic.api.client.notice.created',
[
'%name%' => $client->getName(),
'%clientId%' => $client->getPublicId(),
'%clientSecret%' => $client->getSecret(),
'%url%' => $this->generateUrl(
'mautic_client_action',
[
'objectAction' => 'edit',
'objectId' => $client->getId(),
]
),
]
);
}
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->postActionRedirect(
[
'returnUrl' => $returnUrl,
'contentTemplate' => 'Mautic\ApiBundle\Controller\ClientController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_client_index',
'mauticContent' => 'client',
],
]
);
} elseif ($valid && !$cancelled) {
return $this->editAction($request, $client->getId(), true);
}
}
return $this->delegateView(
[
'viewParameters' => [
'form' => $form->createView(),
'tmpl' => $request->get('tmpl', 'form'),
],
'contentTemplate' => '@MauticApi/Client/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_client_new',
'route' => $action,
'mauticContent' => 'client',
],
]
);
}
/**
* Generates edit form and processes post data.
*
* @param int $objectId
* @param bool $ignorePost
*
* @return JsonResponse|RedirectResponse|Response
*/
public function editAction(Request $request, $objectId, $ignorePost = false)
{
if (!$this->security->isGranted('api:clients:editother')) {
return $this->accessDenied();
}
$client = $this->clientModel->getEntity($objectId);
$returnUrl = $this->generateUrl('mautic_client_index');
$postActionVars = [
'returnUrl' => $returnUrl,
'contentTemplate' => 'Mautic\ApiBundle\Controller\ClientController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_client_index',
'mauticContent' => 'client',
],
];
// client not found
if (null === $client) {
return $this->postActionRedirect(
array_merge(
$postActionVars,
[
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.api.client.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
]
)
);
} elseif ($this->clientModel->isLocked($client)) {
// deny access if the entity is locked
return $this->isLocked($postActionVars, $client, 'api.client');
}
$action = $this->generateUrl('mautic_client_action', ['objectAction' => 'edit', 'objectId' => $objectId]);
$form = $this->clientModel->createForm($client, $this->formFactory, $action);
// remove api_mode field
$form->remove('api_mode');
// /Check for a submitted form and process it
if (!$ignorePost && 'POST' == $request->getMethod()) {
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
// form is valid so process the data
$this->clientModel->saveEntity($client, $this->getFormButton($form, ['buttons', 'save'])->isClicked());
$this->addFlashMessage(
'mautic.core.notice.updated',
[
'%name%' => $client->getName(),
'%menu_link%' => 'mautic_client_index',
'%url%' => $this->generateUrl(
'mautic_client_action',
[
'objectAction' => 'edit',
'objectId' => $client->getId(),
]
),
]
);
if ($this->getFormButton($form, ['buttons', 'save'])->isClicked()) {
return $this->postActionRedirect($postActionVars);
}
}
} else {
// unlock the entity
$this->clientModel->unlockEntity($client);
return $this->postActionRedirect($postActionVars);
}
} else {
// lock the entity
$this->clientModel->lockEntity($client);
}
return $this->delegateView(
[
'viewParameters' => [
'form' => $form->createView(),
'tmpl' => $request->get('tmpl', 'form'),
],
'contentTemplate' => '@MauticApi/Client/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_client_index',
'route' => $action,
'mauticContent' => 'client',
],
]
);
}
/**
* Deletes the entity.
*
* @param int $objectId
*
* @return Response
*/
public function deleteAction(Request $request, $objectId)
{
if (!$this->security->isGranted('api:clients:delete')) {
return $this->accessDenied();
}
$returnUrl = $this->generateUrl('mautic_client_index');
$success = 0;
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'contentTemplate' => 'Mautic\ApiBundle\Controller\ClientController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_client_index',
'success' => $success,
'mauticContent' => 'client',
],
];
if ('POST' === $request->getMethod()) {
$entity = $this->clientModel->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.api.client.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif ($this->clientModel->isLocked($entity)) {
// deny access if the entity is locked
return $this->isLocked($postActionVars, $entity, 'api.client');
} else {
$this->clientModel->deleteEntity($entity);
$name = $entity->getName();
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.core.notice.deleted',
'msgVars' => [
'%name%' => $name,
'%id%' => $objectId,
],
];
}
}
return $this->postActionRedirect(
array_merge(
$postActionVars,
[
'flashes' => $flashes,
]
)
);
}
public function getModelName(): string
{
return 'api.client';
}
}

View File

@@ -0,0 +1,598 @@
<?php
namespace Mautic\ApiBundle\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\ApiEvents;
use Mautic\ApiBundle\Event\ApiEntityEvent;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
/**
* @template E of object
*
* @extends FetchCommonApiController<E>
*/
class CommonApiController extends FetchCommonApiController
{
/**
* @var array
*/
protected $dataInputMasks = [];
/**
* Model object for processing the entity.
*
* @var FormModel<E>|null
*/
protected $model;
/**
* @var array
*/
protected $routeParams = [];
/**
* @var array
*/
protected $entityRequestParameters = [];
public function __construct(
CorePermissions $security,
Translator $translator,
EntityResultHelper $entityResultHelper,
protected RouterInterface $router,
protected FormFactoryInterface $formFactory,
AppVersion $appVersion,
RequestStack $requestStack,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
EventDispatcherInterface $dispatcher,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($security, $translator, $entityResultHelper, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
/**
* Delete a batch of entities.
*
* @return array|Response
*/
public function deleteEntitiesAction(Request $request)
{
$parameters = $request->query->all();
$valid = $this->validateBatchPayload($parameters);
if ($valid instanceof Response) {
return $valid;
}
$errors = [];
$entities = $this->getBatchEntities($parameters, $errors, true);
$this->inBatchMode = true;
// Generate the view before deleting so that the IDs are still populated before Doctrine removes them
$payload = [$this->entityNameMulti => $entities];
$view = $this->view($payload, Response::HTTP_OK);
$this->setSerializationContext($view);
$response = $this->handleView($view);
foreach ($entities as $key => $entity) {
if (null === $entity || !$entity->getId()) {
$this->setBatchError($key, 'mautic.core.error.notfound', Response::HTTP_NOT_FOUND, $errors, $entities, $entity);
continue;
}
if (!$this->checkEntityAccess($entity, 'delete')) {
$this->setBatchError($key, 'mautic.core.error.accessdenied', Response::HTTP_FORBIDDEN, $errors, $entities, $entity);
continue;
}
$this->model->deleteEntity($entity);
$this->doctrine->getManager()->detach($entity);
}
if (!empty($errors)) {
$content = json_decode($response->getContent(), true);
$content['errors'] = $errors;
$response->setContent(json_encode($content));
}
return $response;
}
/**
* Deletes an entity.
*
* @param int $id Entity ID
*
* @return Response
*/
public function deleteEntityAction($id)
{
$entity = $this->model->getEntity($id);
if (null !== $entity) {
if (!$this->checkEntityAccess($entity, 'delete')) {
return $this->accessDenied();
}
$this->model->deleteEntity($entity);
$this->preSerializeEntity($entity);
$view = $this->view([$this->entityNameOne => $entity], Response::HTTP_OK);
$this->setSerializationContext($view);
return $this->handleView($view);
}
return $this->notFound();
}
/**
* Edit a batch of entities.
*
* @return array|Response
*/
public function editEntitiesAction(Request $request)
{
$parameters = $request->request->all();
$valid = $this->validateBatchPayload($parameters);
if ($valid instanceof Response) {
return $valid;
}
$errors = [];
$statusCodes = [];
$entities = $this->getBatchEntities($parameters, $errors);
foreach ($parameters as $key => $params) {
$method = $request->getMethod();
$entity = $entities[$key] ?? null;
$statusCode = Response::HTTP_OK;
if (null === $entity || !$entity->getId()) {
if ('PATCH' === $method) {
// PATCH requires that an entity exists
$this->setBatchError($key, 'mautic.core.error.notfound', Response::HTTP_NOT_FOUND, $errors, $entities, $entity);
$statusCodes[$key] = Response::HTTP_NOT_FOUND;
continue;
}
// PUT can create a new entity if it doesn't exist
$entity = $this->model->getEntity();
if (!$this->checkEntityAccess($entity, 'create')) {
$this->setBatchError($key, 'mautic.core.error.accessdenied', Response::HTTP_FORBIDDEN, $errors, $entities, $entity);
$statusCodes[$key] = Response::HTTP_FORBIDDEN;
continue;
}
$statusCode = Response::HTTP_CREATED;
}
if (!$this->checkEntityAccess($entity, 'edit')) {
$this->setBatchError($key, 'mautic.core.error.accessdenied', Response::HTTP_FORBIDDEN, $errors, $entities, $entity);
$statusCodes[$key] = Response::HTTP_FORBIDDEN;
continue;
}
$this->processBatchForm($request, $key, $entity, $params, $method, $errors, $entities);
if (isset($errors[$key])) {
$statusCodes[$key] = $errors[$key]['code'];
} else {
$statusCodes[$key] = $statusCode;
}
}
$payload = [
$this->entityNameMulti => $entities,
'statusCodes' => $statusCodes,
];
if (!empty($errors)) {
$payload['errors'] = $errors;
}
$view = $this->view($payload, Response::HTTP_OK);
$this->setSerializationContext($view);
return $this->handleView($view);
}
/**
* Edits an existing entity or creates one on PUT if it doesn't exist.
*
* @param int $id Entity ID
*
* @return Response
*/
public function editEntityAction(Request $request, $id)
{
$entity = $this->model->getEntity($id);
$parameters = $request->request->all();
$method = $request->getMethod();
if (null === $entity || !$entity->getId()) {
if ('PATCH' === $method) {
// PATCH requires that an entity exists
return $this->notFound();
}
// PUT can create a new entity if it doesn't exist
$entity = $this->model->getEntity();
if (!$this->checkEntityAccess($entity, 'create')) {
return $this->accessDenied();
}
}
if (!$this->checkEntityAccess($entity, 'edit')) {
return $this->accessDenied();
}
return $this->processForm($request, $entity, $parameters, $method);
}
/**
* Create a batch of new entities.
*
* @return array|Response
*/
public function newEntitiesAction(Request $request)
{
$entity = $this->model->getEntity();
if (!$this->checkEntityAccess($entity, 'create')) {
return $this->accessDenied();
}
$parameters = $request->request->all();
$valid = $this->validateBatchPayload($parameters);
if ($valid instanceof Response) {
return $valid;
}
$this->inBatchMode = true;
$entities = [];
$errors = [];
$statusCodes = [];
foreach ($parameters as $key => $params) {
// Can be new or an existing on based on params
$entity = $this->getNewEntity($params);
$entityExists = false;
$method = 'POST';
if ($entity->getId()) {
$entityExists = true;
$method = 'PATCH';
if (!$this->checkEntityAccess($entity, 'edit')) {
$this->setBatchError($key, 'mautic.core.error.accessdenied', Response::HTTP_FORBIDDEN, $errors, $entities, $entity);
$statusCodes[$key] = Response::HTTP_FORBIDDEN;
continue;
}
}
$this->processBatchForm($request, $key, $entity, $params, $method, $errors, $entities);
if (isset($errors[$key])) {
$statusCodes[$key] = $errors[$key]['code'];
} elseif ($entityExists) {
$statusCodes[$key] = Response::HTTP_OK;
} else {
$statusCodes[$key] = Response::HTTP_CREATED;
}
}
$payload = [
$this->entityNameMulti => $entities,
'statusCodes' => $statusCodes,
];
if (!empty($errors)) {
$payload['errors'] = $errors;
}
$view = $this->view($payload, Response::HTTP_CREATED);
$this->setSerializationContext($view);
return $this->handleView($view);
}
/**
* Creates a new entity.
*
* @return Response
*/
public function newEntityAction(Request $request)
{
$parameters = $request->request->all();
$entity = $this->getNewEntity($parameters);
if (!$this->checkEntityAccess($entity, 'create')) {
return $this->accessDenied();
}
return $this->processForm($request, $entity, $parameters, 'POST');
}
/**
* @return FormInterface<mixed>
*/
protected function createEntityForm($entity): FormInterface
{
return $this->model->createForm(
$entity,
$this->formFactory,
null,
array_merge(
[
'csrf_protection' => false,
'allow_extra_fields' => true,
],
$this->getEntityFormOptions()
)
);
}
/**
* Gives child controllers opportunity to analyze and do whatever to an entity before populating the form.
*
* @param string $action
*
* @return mixed
*/
protected function prePopulateForm(&$entity, $parameters, $action = 'edit')
{
}
/**
* Give the controller an opportunity to process the entity before persisting.
*
* @return mixed
*/
protected function preSaveEntity(&$entity, $form, $parameters, $action = 'edit')
{
}
/**
* Convert posted parameters into what the form needs in order to successfully bind.
*
* @param mixed[] $parameters
* @param object $entity
* @param string $action
*
* @return mixed
*/
protected function prepareParametersForBinding(Request $request, $parameters, $entity, $action)
{
return $parameters;
}
protected function processBatchForm(Request $request, $key, $entity, $params, $method, &$errors, &$entities)
{
$this->inBatchMode = true;
$formResponse = $this->processForm($request, $entity, $params, $method);
if ($formResponse instanceof Response) {
if (!$formResponse instanceof RedirectResponse) {
// Assume an error
$this->setBatchError(
$key,
InputHelper::string($formResponse->getContent()),
$formResponse->getStatusCode(),
$errors,
$entities,
$entity
);
}
} elseif (is_object($formResponse) && $formResponse::class === $entity::class) {
// Success
$entities[$key] = $formResponse;
} elseif (is_array($formResponse) && isset($formResponse['code'], $formResponse['message'])) {
// There was an error
$errors[$key] = $formResponse;
}
$lastEntityIndex = -1;
foreach ($entities as $index => $moreEntities) {
if ($moreEntities !== $entity) {
continue;
}
$lastEntityIndex = $index;
}
if (-1 === $lastEntityIndex || $lastEntityIndex === $key) {
$this->detachEntity($entity);
}
$this->inBatchMode = false;
}
/**
* Processes API Form.
*
* @param array<mixed>|null $parameters
* @param string $method
*
* @return mixed
*/
protected function processForm(Request $request, $entity, $parameters = null, $method = 'PUT')
{
$categoryId = null;
if (null === $parameters) {
// get from request
$parameters = $request->request->all();
}
// Store the original parameters from the request so that callbacks can have access to them as needed
$this->entityRequestParameters = $parameters;
// unset the ID in the parameters if set as this will cause the form to fail
if (isset($parameters['id'])) {
unset($parameters['id']);
}
// is an entity being updated or created?
if ($entity->getId()) {
$statusCode = Response::HTTP_OK;
$action = 'edit';
} else {
$statusCode = Response::HTTP_CREATED;
$action = 'new';
// All the properties have to be defined in order for validation to work
// Bug reported https://github.com/symfony/symfony/issues/19788
$defaultProperties = $this->getEntityDefaultProperties($entity);
$parameters = array_merge($defaultProperties, $parameters);
}
// Check if user has access to publish
if (
(
array_key_exists('isPublished', $parameters)
|| array_key_exists('publishUp', $parameters)
|| array_key_exists('publishDown', $parameters)
)
&& $this->security->checkPermissionExists($this->permissionBase.':publish')) {
if ($this->security->checkPermissionExists($this->permissionBase.':publishown')) {
if (!$this->checkEntityAccess($entity, 'publish')) {
if ('new' === $action) {
$parameters['isPublished'] = 0;
unset($parameters['publishUp'], $parameters['publishDown']);
} else {
unset($parameters['isPublished'], $parameters['publishUp'], $parameters['publishDown']);
}
}
}
}
$form = $this->createEntityForm($entity);
$submitParams = $this->prepareParametersForBinding($request, $parameters, $entity, $action);
if ($submitParams instanceof Response) {
return $submitParams;
}
// Remove category from the payload because it will cause form validation error.
if (isset($submitParams['category'])) {
$categoryId = (int) $submitParams['category'];
unset($submitParams['category']);
}
$this->prepareParametersFromRequest($form, $submitParams, $entity, $this->dataInputMasks);
$form->submit($submitParams, 'PATCH' !== $method);
if ($form->isSubmitted() && $form->isValid()) {
$this->setCategory($entity, $categoryId);
$preSaveError = $this->preSaveEntity($entity, $form, $submitParams, $action);
if ($preSaveError instanceof Response) {
return $preSaveError;
}
try {
if ($this->dispatcher->hasListeners(ApiEvents::API_ON_ENTITY_PRE_SAVE)) {
$this->dispatcher->dispatch(new ApiEntityEvent($entity, $this->entityRequestParameters, $request), ApiEvents::API_ON_ENTITY_PRE_SAVE);
}
} catch (\Exception $e) {
return $this->returnError($e->getMessage(), $e->getCode());
}
$statusCode = $this->saveEntity($entity, $statusCode);
$headers = [];
// return the newly created entities location if applicable
if (in_array($statusCode, [Response::HTTP_CREATED, Response::HTTP_ACCEPTED])) {
$route = (null !== $this->router->getRouteCollection()->get('mautic_api_'.$this->entityNameMulti.'_getone'))
? 'mautic_api_'.$this->entityNameMulti.'_getone' : 'mautic_api_get'.$this->entityNameOne;
$headers['Location'] = $this->generateUrl(
$route,
array_merge(['id' => $entity->getId()], $this->routeParams),
true
);
}
try {
if ($this->dispatcher->hasListeners(ApiEvents::API_ON_ENTITY_POST_SAVE)) {
$this->dispatcher->dispatch(new ApiEntityEvent($entity, $this->entityRequestParameters, $request), ApiEvents::API_ON_ENTITY_POST_SAVE);
}
} catch (\Exception $e) {
return $this->returnError($e->getMessage(), $e->getCode());
}
$this->preSerializeEntity($entity, $action);
if ($this->inBatchMode) {
return $entity;
} else {
$view = $this->view([$this->entityNameOne => $entity], $statusCode, $headers);
}
$this->setSerializationContext($view);
} else {
$formErrors = $this->getFormErrorMessages($form);
$formErrorCodes = $this->getFormErrorCodes($form);
$msg = $this->getFormErrorMessage($formErrors);
if (!$msg) {
$msg = $this->translator->trans('mautic.core.error.badrequest', [], 'flashes');
}
$responseCode = in_array(Response::HTTP_UNPROCESSABLE_ENTITY, $formErrorCodes) ? Response::HTTP_UNPROCESSABLE_ENTITY : Response::HTTP_BAD_REQUEST;
return $this->returnError($msg, $responseCode, $formErrors);
}
return $this->handleView($view);
}
protected function saveEntity($entity, int $statusCode): int
{
$this->model->saveEntity($entity);
return $statusCode;
}
/**
* @param object $entity
* @param int $categoryId
*
* @throws \UnexpectedValueException
*/
protected function setCategory($entity, $categoryId)
{
if (!empty($categoryId) && method_exists($entity, 'setCategory')) {
$category = $this->doctrine->getManager()->find(Category::class, $categoryId);
if (null === $category) {
throw new \UnexpectedValueException("Category $categoryId does not exist");
}
$entity->setCategory($category);
}
}
/**
* Entity not to be detached in case of Lead Batch API.
*/
protected function detachEntity(object $entity): void
{
$this->doctrine->getManager()->detach($entity);
}
}

View File

@@ -0,0 +1,773 @@
<?php
namespace Mautic\ApiBundle\Controller;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Doctrine\Persistence\ManagerRegistry;
use FOS\RestBundle\Controller\AbstractFOSRestController;
use FOS\RestBundle\View\View;
use JMS\Serializer\Exclusion\ExclusionStrategyInterface;
use Mautic\ApiBundle\ApiEvents;
use Mautic\ApiBundle\Event\ApiInitializeEvent;
use Mautic\ApiBundle\Event\ApiSerializationContextEvent;
use Mautic\ApiBundle\Helper\BatchIdToEntityHelper;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\ApiBundle\Serializer\Exclusion\ParentChildrenExclusionStrategy;
use Mautic\ApiBundle\Serializer\Exclusion\PublishDetailsExclusionStrategy;
use Mautic\CoreBundle\Controller\FormErrorMessagesTrait;
use Mautic\CoreBundle\Controller\MauticController;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Form\RequestTrait;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\AbstractCommonModel;
use Mautic\CoreBundle\Model\MauticModelInterface;
use Mautic\CoreBundle\Security\Exception\PermissionException;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
/**
* @template E of object
*/
class FetchCommonApiController extends AbstractFOSRestController implements MauticController
{
use RequestTrait;
use FormErrorMessagesTrait;
/**
* If set to true, serializer will not return null values.
*
* @var bool
*/
protected $customSelectRequested = false;
/**
* Class for the entity.
*
* @var class-string<E>
*/
protected $entityClass;
/**
* Key to return for entity lists.
*
* @var string
*/
protected $entityNameMulti;
/**
* Key to return for a single entity.
*
* @var string
*/
protected $entityNameOne;
/**
* Custom JMS strategies to add to the view's context.
*
* @var array<int, ExclusionStrategyInterface>
*/
protected $exclusionStrategies = [];
/**
* Pass to the model's getEntities() method.
*
* @var array<mixed>
*/
protected $extraGetEntitiesArguments = [];
/**
* @var bool
*/
protected $inBatchMode = false;
/**
* Used to set default filters for entity lists such as restricting to owning user.
*
* @var array<array<string, mixed>>
*/
protected $listFilters = [];
/**
* Model object for processing the entity.
*
* @var AbstractCommonModel<E>|null
*/
protected $model;
/**
* The level parent/children should stop loading if applicable.
*
* @var int
*/
protected $parentChildrenLevelDepth = 3;
/**
* Permission base for the entity such as page:pages.
*
* @var string|null
*/
protected $permissionBase;
/**
* @var array<int, string>
*/
protected $serializerGroups = [];
/**
* @var Translator
*/
protected $translator;
protected ContainerBagInterface $parametersContainer;
/**
* @param ModelFactory<E> $modelFactory
*/
public function __construct(
protected CorePermissions $security,
Translator $translator,
protected EntityResultHelper $entityResultHelper,
private AppVersion $appVersion,
private RequestStack $requestStack,
protected ManagerRegistry $doctrine,
protected ModelFactory $modelFactory,
protected EventDispatcherInterface $dispatcher,
protected CoreParametersHelper $coreParametersHelper,
) {
$this->translator = $translator;
if (null !== $this->model && !$this->permissionBase && method_exists($this->model, 'getPermissionBase')) {
$this->permissionBase = $this->model->getPermissionBase();
}
$event = new ApiInitializeEvent(
(string) $this->entityClass,
$this->serializerGroups,
$this->exclusionStrategies,
);
$this->dispatcher->dispatch($event);
$this->serializerGroups = $event->getSerializerGroups();
$this->exclusionStrategies = $event->getExclusionStrategies();
}
/**
* Obtains a list of entities as defined by the API URL.
*
* @return Response
*/
public function getEntitiesAction(Request $request, UserHelper $userHelper)
{
$repo = $this->model->getRepository();
$tableAlias = $repo->getTableAlias();
$publishedOnly = $request->get('published', 0);
$minimal = $request->get('minimal', 0);
try {
if (!$this->security->isGranted($this->permissionBase.':view')) {
return $this->accessDenied();
}
} catch (PermissionException $e) {
return $this->accessDenied($e->getMessage());
}
if ($this->security->checkPermissionExists($this->permissionBase.':viewother')
&& !$this->security->isGranted($this->permissionBase.':viewother')
&& null !== $user = $userHelper->getUser()
) {
$this->listFilters[] = [
'column' => $tableAlias.'.createdBy',
'expr' => 'eq',
'value' => $user->getId(),
];
}
if ($publishedOnly) {
$this->listFilters[] = [
'column' => $tableAlias.'.isPublished',
'expr' => 'eq',
'value' => true,
];
}
if ($minimal) {
if (isset($this->serializerGroups[0])) {
$this->serializerGroups[0] = str_replace('Details', 'List', $this->serializerGroups[0]);
}
}
$args = array_merge(
[
'start' => $request->query->get('start', 0),
'limit' => $request->query->get('limit', $this->coreParametersHelper->get('default_pagelimit')),
'filter' => [
'string' => $request->query->get('search', ''),
'force' => $this->listFilters,
],
'orderBy' => $this->addAliasIfNotPresent($request->query->get('orderBy', ''), $tableAlias),
'orderByDir' => $request->query->get('orderByDir', 'ASC'),
'withTotalCount' => true, // for repositories that break free of Paginator
],
$this->extraGetEntitiesArguments
);
if ($select = InputHelper::cleanArray($request->query->all()['select'] ?? $request->request->all()['select'] ?? [])) {
$args['select'] = $select;
$this->customSelectRequested = true;
}
if ($where = $this->getWhereFromRequest($request)) {
$args['filter']['where'] = $where;
}
if ($order = $this->getOrderFromRequest($request)) {
$args['filter']['order'] = $order;
}
if ($totalCountTtl = $this->getTotalCountTtl()) {
$args['totalCountTtl'] = $totalCountTtl;
}
$results = $this->model->getEntities($args);
[$entities, $totalCount] = $this->prepareEntitiesForView($results);
$view = $this->view(
[
'total' => $totalCount,
$this->entityNameMulti => $entities,
],
Response::HTTP_OK
);
$this->setSerializationContext($view);
return $this->handleView($view);
}
/**
* Sanitizes and returns an array of where statements from the request.
*
* @return array<mixed>
*/
protected function getWhereFromRequest(Request $request)
{
$where = $request->query->all()['where'] ?? [];
$this->sanitizeWhereClauseArrayFromRequest($where);
return $where;
}
/**
* Sanitizes and returns an array of ORDER statements from the request.
*
* @return array<mixed>
*/
protected function getOrderFromRequest(Request $request): array
{
return InputHelper::cleanArray($request->query->all()['order'] ?? []);
}
/**
* Adds the repository alias to the column name if it doesn't exist.
*
* @return string $column name with alias prefix
*/
protected function addAliasIfNotPresent(string $columns, string $alias): string
{
if (!$columns) {
return $columns;
}
$columns = explode(',', trim($columns));
$prefix = $alias.'.';
array_walk(
$columns,
function (&$column, $key, $prefix): void {
$column = trim($column);
if (1 === count(explode('.', $column))) {
$column = $prefix.$column;
}
},
$prefix
);
return implode(',', $columns);
}
/**
* Obtains a specific entity as defined by the API URL.
*
* @param int $id Entity ID
*
* @return Response
*/
public function getEntityAction(Request $request, $id)
{
$args = [];
if ($select = InputHelper::cleanArray($request->get('select', []))) {
$args['select'] = $select;
$this->customSelectRequested = true;
}
if (!empty($args)) {
$args['id'] = $id;
$entity = $this->model->getEntity($args);
} else {
$entity = $this->model->getEntity($id);
}
if (!$entity instanceof $this->entityClass) {
return $this->notFound();
}
if (!$this->checkEntityAccess($entity)) {
return $this->accessDenied();
}
$this->preSerializeEntity($entity);
$view = $this->view([$this->entityNameOne => $entity], Response::HTTP_OK);
$this->setSerializationContext($view);
return $this->handleView($view);
}
/**
* Creates new entity from provided params.
*
* @param array<mixed> $params
*
* @return object
*/
public function getNewEntity(array $params)
{
return $this->model->getEntity();
}
public function getCurrentRequest(): Request
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
throw new \RuntimeException('Request is not set.');
}
return $request;
}
/**
* Alias for notFound method. It's used in the LeadAccessTrait.
*
* @param array<mixed> $args
*
* @return Response
*/
public function postActionRedirect(array $args = [])
{
return $this->notFound('mautic.contact.error.notfound');
}
/**
* Returns a 403 Access Denied.
*
* @param string $msg
*
* @return Response
*/
protected function accessDenied($msg = 'mautic.core.error.accessdenied')
{
return $this->returnError($msg, Response::HTTP_FORBIDDEN);
}
protected function addExclusionStrategy(ExclusionStrategyInterface $strategy): void
{
$this->exclusionStrategies[] = $strategy;
}
/**
* Returns a 400 Bad Request.
*
* @param string $msg
*
* @return Response
*/
protected function badRequest($msg = 'mautic.core.error.badrequest')
{
return $this->returnError($msg, Response::HTTP_BAD_REQUEST);
}
/**
* Checks if user has permission to access retrieved entity.
*
* @param FormEntity $entity
* @param string $action view|create|edit|publish|delete
*
* @return bool|Response
*/
protected function checkEntityAccess($entity, $action = 'view')
{
$ownPerm = "{$this->permissionBase}:{$action}own";
$otherPerm = "{$this->permissionBase}:{$action}other";
if ('publish' === $action) {
return $this->security->hasPublishAccessForEntity($entity, $ownPerm, $otherPerm);
}
if ('create' !== $action && is_object($entity) && method_exists($entity, 'getCreatedBy')) {
$owner = (method_exists($entity, 'getPermissionUser')) ? $entity->getPermissionUser() : $entity->getCreatedBy();
return $this->security->hasEntityAccess($ownPerm, $otherPerm, $owner);
}
try {
return $this->security->isGranted("{$this->permissionBase}:{$action}");
} catch (PermissionException $e) {
return $this->accessDenied($e->getMessage());
}
}
/**
* @param mixed[] $parameters
* @param mixed[] $errors
* @param bool $prepareForSerialization
* @param string $requestIdColumn
* @param MauticModelInterface|null $model
* @param bool $returnWithOriginalKeys
*
* @return mixed[]
*/
protected function getBatchEntities($parameters, &$errors, $prepareForSerialization = false, $requestIdColumn = 'id', $model = null, $returnWithOriginalKeys = true): array
{
$idHelper = new BatchIdToEntityHelper($parameters, $requestIdColumn);
if (!$idHelper->hasIds()) {
return [];
}
/** @var AbstractCommonModel<object> $model */
$model = $model ?: $this->model;
$entities = $model->getEntities(
[
'filter' => [
'force' => [
[
'column' => $model->getRepository()->getTableAlias().'.id',
'expr' => 'in',
'value' => $idHelper->getIds(),
],
],
],
'ignore_paginator' => true,
]
);
// It must be associative because the order of entities has changed
$idHelper->setIsAssociative(true);
[$entities, $total] = $prepareForSerialization
?
$this->prepareEntitiesForView($entities)
:
$this->prepareEntityResultsToArray($entities);
// Set errors
if ($idHelper->hasErrors()) {
foreach ($idHelper->getErrors() as $key => $error) {
$this->setBatchError($key, $error, Response::HTTP_BAD_REQUEST, $errors);
}
}
// Return the response with matching keys from the request
if ($returnWithOriginalKeys) {
if ($entities instanceof \ArrayObject) {
$entities = $entities->getArrayCopy();
}
return $idHelper->orderByOriginalKey($entities);
}
// Return the response with IDs as keys (default behavior)
$return = [];
foreach ($entities as $entity) {
$return[$entity->getId()] = $entity;
}
return $return;
}
/**
* Get the default properties of an entity and parents.
*
* @phpstan-param E $entity
*
* @return array<mixed>
*/
protected function getEntityDefaultProperties(object $entity): array
{
$class = $entity::class;
$chain = array_reverse(class_parents($entity), true) + [$class => $class];
$defaultValues = [];
$classMetdata = new ClassMetadata($class);
foreach ($chain as $class) {
if (method_exists($class, 'loadMetadata')) {
$class::loadMetadata($classMetdata);
}
$defaultValues += (new \ReflectionClass($class))->getDefaultProperties();
}
// These are the mapped columns
$fields = $classMetdata->getFieldNames();
// Merge values in with $fields
$properties = [];
foreach ($fields as $field) {
$properties[$field] = $defaultValues[$field];
}
return $properties;
}
/**
* Append options to the form.
*
* @return array<string, mixed>
*/
protected function getEntityFormOptions(): array
{
return [];
}
/**
* Get a model instance from the service container.
*
* @return AbstractCommonModel<E>
*/
protected function getModel(string $modelNameKey): AbstractCommonModel
{
return $this->modelFactory->getModel($modelNameKey);
}
/**
* Returns a 404 Not Found.
*
* @return Response
*/
protected function notFound(string $msg = 'mautic.core.error.notfound')
{
return $this->returnError($msg, Response::HTTP_NOT_FOUND);
}
/**
* Gives child controllers opportunity to analyze and do whatever to an entity before going through serializer.
*
* @phpstan-param E $entity
*/
protected function preSerializeEntity(object $entity, string $action = 'view'): void
{
}
/**
* Prepares entities returned from repository getEntities().
*
* @param array<mixed>|Paginator<E> $results
*
* @return array{0: array<mixed>|\ArrayObject<int,mixed>, 1: int}
*/
protected function prepareEntitiesForView($results): array
{
return $this->prepareEntityResultsToArray(
$results,
function ($entity): void {
$this->preSerializeEntity($entity);
}
);
}
/**
* @param array<mixed>|Paginator<E> $results
* @param callable|null $callback
*
* @return array{0: array<mixed>|\ArrayObject<int,mixed>, 1: int}
*/
protected function prepareEntityResultsToArray($results, $callback = null): array
{
if (is_array($results) && isset($results['count'])) {
$totalCount = $results['count'];
$results = $results['results'];
} else {
$totalCount = count($results);
}
$entities = $this->entityResultHelper->getArray($results, $callback);
return [$entities, $totalCount];
}
/**
* Returns an error.
*
* @param array<mixed> $details
*
* @return Response|array<string, array<mixed>|int|string|null>
*/
protected function returnError(string $msg, int $code = Response::HTTP_INTERNAL_SERVER_ERROR, array $details = [])
{
if ($this->translator->hasId($msg, 'flashes')) {
$msg = $this->translator->trans($msg, [], 'flashes');
} elseif ($this->translator->hasId($msg, 'messages')) {
$msg = $this->translator->trans($msg, [], 'messages');
}
$error = [
'code' => $code,
'message' => $msg,
'details' => $details,
'type' => null,
];
if ($this->inBatchMode) {
return $error;
}
$view = $this->view(
[
'errors' => [
$error,
],
],
$code
);
return $this->handleView($view);
}
/**
* @param array<mixed> $where
*/
protected function sanitizeWhereClauseArrayFromRequest(array &$where): void
{
foreach ($where as $key => $statement) {
if (isset($statement['internal'])) {
unset($where[$key]);
} elseif (in_array($statement['expr'], ['andX', 'orX'])) {
$this->sanitizeWhereClauseArrayFromRequest($statement['val']);
}
}
}
/**
* @param array<int, array<string|int>> $errors
* @param array<int, object|null> $entities
*
* @phpstan-param E|null $entity
* @phpstan-param array<int, E|null> $entities
*/
protected function setBatchError(int $key, string $msg, int $code, array &$errors, array &$entities = [], ?object $entity = null): void
{
unset($entities[$key]);
if ($entity) {
$this->doctrine->getManager()->detach($entity);
}
$errors[$key] = [
'message' => $this->translator->hasId($msg, 'flashes') ? $this->translator->trans($msg, [], 'flashes') : $msg,
'code' => $code,
'type' => 'api',
];
}
/**
* Set serialization groups and exclusion strategies.
*/
protected function setSerializationContext(View $view): void
{
$context = $view->getContext();
if ($this->dispatcher->hasListeners(ApiEvents::API_PRE_SERIALIZATION_CONTEXT)) {
$apiSerializationContextEvent = new ApiSerializationContextEvent($context, $this->getCurrentRequest());
$this->dispatcher->dispatch($apiSerializationContextEvent, ApiEvents::API_PRE_SERIALIZATION_CONTEXT);
$context = $apiSerializationContextEvent->getContext();
}
if (!empty($this->serializerGroups)) {
$context->setGroups($this->serializerGroups);
}
// Only include FormEntity properties for the top level entity and not the associated entities
$context->addExclusionStrategy(
new PublishDetailsExclusionStrategy()
);
// Only include first level of children/parents
if ($this->parentChildrenLevelDepth) {
$context->addExclusionStrategy(
new ParentChildrenExclusionStrategy($this->parentChildrenLevelDepth)
);
}
// Add custom exclusion strategies
foreach ($this->exclusionStrategies as $strategy) {
$context->addExclusionStrategy($strategy);
}
// Include null values if a custom select has not been given
if (!$this->customSelectRequested) {
$context->setSerializeNull(true);
}
if ($this->dispatcher->hasListeners(ApiEvents::API_POST_SERIALIZATION_CONTEXT)) {
$apiSerializationContextEvent = new ApiSerializationContextEvent($context, $this->getCurrentRequest());
$this->dispatcher->dispatch($apiSerializationContextEvent, ApiEvents::API_POST_SERIALIZATION_CONTEXT);
$context = $apiSerializationContextEvent->getContext();
}
$view->setContext($context);
}
/**
* @param array<mixed> $parameters
*
* @return array<string, array<mixed>|int|string|null>|bool|Response
*/
protected function validateBatchPayload(array $parameters)
{
$batchLimit = (int) $this->coreParametersHelper->get('api_batch_max_limit', 200);
if (count($parameters) > $batchLimit) {
return $this->returnError($this->translator->trans('mautic.api.call.batch_exception', ['%limit%' => $batchLimit]));
}
return true;
}
/**
* @param mixed|null $data
* @param array<string, string|int> $headers
*/
protected function view($data = null, ?int $statusCode = null, array $headers = []): View
{
if ($data instanceof Paginator) {
// Get iterator out of Paginator class so that the entities are properly serialized by the serializer
$data = iterator_to_array($data->getIterator(), true);
}
$headers['Mautic-Version'] = $this->appVersion->getVersion();
return parent::view($data, $statusCode, $headers);
}
protected function getTotalCountTtl(): ?int
{
return null;
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Mautic\ApiBundle\Controller\oAuth2;
use FOS\OAuthServerBundle\Form\Handler\AuthorizeFormHandler;
use FOS\OAuthServerBundle\Model\ClientManagerInterface;
use OAuth2\OAuth2;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
class AuthorizeController extends \FOS\OAuthServerBundle\Controller\AuthorizeController
{
private TokenStorageInterface $tokenStorage;
/**
* This constructor must be duplicated from the extended class so our custom code could access the properties.
*/
public function __construct(
RequestStack $requestStack,
Form $authorizeForm,
OAuth2 $oAuth2Server,
TokenStorageInterface $tokenStorage,
UrlGeneratorInterface $router,
ClientManagerInterface $clientManager,
EventDispatcherInterface $eventDispatcher,
) {
parent::__construct(
$requestStack,
$authorizeForm,
$oAuth2Server,
$tokenStorage,
$router,
$clientManager,
$eventDispatcher
);
$this->tokenStorage = $tokenStorage;
}
/**
* @param array<string , mixed> $data Various data to be passed to the twig template
*
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
protected function renderAuthorize(array $data, Environment $twig): Response
{
$response = $twig->render(
'@MauticApi/Authorize/oAuth2/authorize.html.twig',
$data
);
return new Response($response);
}
public function authorizeAction(Request $request, AuthorizeFormHandler $formHandler, Environment $twig): Response
{
// The parent bundle does not care about token being empty.
if (null === $this->tokenStorage->getToken()) {
throw new AccessDeniedException('This user does not have access to this section. No token.');
}
return parent::authorizeAction($request, $formHandler, $twig);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Mautic\ApiBundle\Controller\oAuth2;
use Mautic\CoreBundle\Controller\CommonController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
class SecurityController extends CommonController
{
public function loginAction(Request $request): Response
{
$session = $request->getSession();
// get the login error if there is one
if ($request->attributes->has(SecurityRequestAttributes::AUTHENTICATION_ERROR)) {
$error = $request->attributes->get(SecurityRequestAttributes::AUTHENTICATION_ERROR);
} else {
$error = $session->get(SecurityRequestAttributes::AUTHENTICATION_ERROR);
$session->remove(SecurityRequestAttributes::AUTHENTICATION_ERROR);
}
if (!empty($error)) {
if ($error instanceof Exception\BadCredentialsException) {
$msg = 'mautic.user.auth.error.invalidlogin';
} else {
$msg = $error->getMessage();
}
$this->addFlashMessage($msg, [], 'error', null, false);
}
if ($session->has('_security.target_path')) {
if (str_contains($session->get('_security.target_path'), $this->generateUrl('fos_oauth_server_authorize'))) {
$session->set('_fos_oauth_server.ensure_logout', true);
}
}
return $this->render(
'@MauticApi/Security/login.html.twig',
[
'last_username' => $session->get(SecurityRequestAttributes::LAST_USERNAME),
'route' => 'mautic_oauth2_server_auth_login_check',
]
);
}
public function loginCheckAction(): Response
{
return new Response('', 400);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\DependencyInjection\Compiler;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
class SerializerPass implements CompilerPassInterface
{
/**
* Replaces the available metadata drivers (yaml, xml, and annotation)
* with our metadata driver, as we do not use any of those. There's
* currently no other way that I can find to get our driver into the
* chain in front of the rest.
*/
public function process(ContainerBuilder $container): void
{
if ($container->hasDefinition('jms_serializer.metadata.doctrine_type_driver')) {
$definition = $container->getDefinition('jms_serializer.metadata.doctrine_type_driver');
$definition->replaceArgument(0, new Reference(ApiMetadataDriver::class));
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticApiExtension 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,44 @@
<?php
namespace Mautic\ApiBundle\Entity\oAuth2;
use Doctrine\ORM\Mapping as ORM;
use FOS\OAuthServerBundle\Model\AccessToken as BaseAccessToken;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class AccessToken extends BaseAccessToken
{
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('oauth2_accesstokens')
->addIndex(['token'], 'oauth2_access_token_search');
$builder->createField('id', 'integer')
->makePrimaryKey()
->generatedValue()
->build();
$builder->createManyToOne('client', 'Client')
->addJoinColumn('client_id', 'id', false, false, 'CASCADE')
->build();
$builder->createManyToOne('user', \Mautic\UserBundle\Entity\User::class)
->addJoinColumn('user_id', 'id', true, false, 'CASCADE')
->build();
$builder->createField('token', 'string')
->unique()
->build();
$builder->createField('expiresAt', 'bigint')
->columnName('expires_at')
->nullable()
->build();
$builder->createField('scope', 'string')
->nullable()
->build();
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Mautic\ApiBundle\Entity\oAuth2;
use Doctrine\ORM\Mapping as ORM;
use FOS\OAuthServerBundle\Model\AuthCode as BaseAuthCode;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class AuthCode extends BaseAuthCode
{
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('oauth2_authcodes');
$builder->createField('id', 'integer')
->makePrimaryKey()
->generatedValue()
->build();
$builder->createManyToOne('client', 'Client')
->addJoinColumn('client_id', 'id', false, false, 'CASCADE')
->build();
$builder->createManyToOne('user', \Mautic\UserBundle\Entity\User::class)
->addJoinColumn('user_id', 'id', false, false, 'CASCADE')
->build();
$builder->createField('token', 'string')
->unique()
->build();
$builder->createField('expiresAt', 'bigint')
->columnName('expires_at')
->nullable()
->build();
$builder->createField('scope', 'string')
->nullable()
->build();
$builder->createField('redirectUri', 'text')
->columnName('redirect_uri')
->build();
}
}

View File

@@ -0,0 +1,250 @@
<?php
namespace Mautic\ApiBundle\Entity\oAuth2;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use FOS\OAuthServerBundle\Model\Client as BaseClient;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\User;
use OAuth2\OAuth2;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
class Client extends BaseClient
{
/**
* @var int
*/
protected $id;
/**
* @var string
*/
protected $name;
/**
* @var ArrayCollection<int, User>
*/
protected $users;
/**
* @var ArrayCollection
*/
protected $authCodes;
protected ?string $randomId = null;
protected ?string $secret = null;
/**
* @var array<string>
*/
protected array $redirectUris = [];
/**
* @var array<string>
*/
protected array $allowedGrantTypes;
protected ?Role $role;
public function __construct()
{
parent::__construct();
$this->allowedGrantTypes = [
OAuth2::GRANT_TYPE_AUTH_CODE,
OAuth2::GRANT_TYPE_REFRESH_TOKEN,
];
$this->users = new ArrayCollection();
$this->authCodes = new ArrayCollection();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('oauth2_clients')
->setCustomRepositoryClass(ClientRepository::class)
->addIndex(['random_id'], 'client_id_search');
$builder->addIdColumns('name', false);
$builder->createManyToMany('users', User::class)
->setJoinTable('oauth2_user_client_xref')
->addInverseJoinColumn('user_id', 'id', false, false, 'CASCADE')
->addJoinColumn('client_id', 'id', false, false, 'CASCADE')
->fetchExtraLazy()
->build();
$builder->createField('randomId', 'string')
->columnName('random_id')
->build();
$builder->addField('secret', 'string');
$builder->createField('redirectUris', 'array')
->columnName('redirect_uris')
->build();
$builder->createField('allowedGrantTypes', 'array')
->columnName('allowed_grant_types')
->build();
$builder->createManyToOne('role', Role::class)
->addJoinColumn('role_id', 'id', true, false)
->cascadePersist()
->build();
}
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint('name', new Assert\NotBlank(
['message' => 'mautic.core.name.required']
));
$metadata->addPropertyConstraint('redirectUris', new Assert\NotBlank(
['message' => 'mautic.api.client.redirecturis.notblank']
));
}
/**
* @var array
*/
protected $changes;
protected function isChanged($prop, $val)
{
$getter = 'get'.ucfirst($prop);
$current = $this->$getter();
if ($current != $val) {
$this->changes[$prop] = [$current, $val];
}
}
/**
* @return array
*/
public function getChanges()
{
return $this->changes;
}
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @param string $name
*
* @return Client
*/
public function setName($name)
{
$this->isChanged('name', $name);
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getName()
{
return $this->name;
}
public function setRedirectUris(array $redirectUris): void
{
$this->isChanged('redirectUris', $redirectUris);
$this->redirectUris = $redirectUris;
}
/**
* @return Client
*/
public function addAuthCode(AuthCode $authCodes)
{
$this->authCodes[] = $authCodes;
return $this;
}
public function removeAuthCode(AuthCode $authCodes): void
{
$this->authCodes->removeElement($authCodes);
}
/**
* @return \Doctrine\Common\Collections\Collection
*/
public function getAuthCodes()
{
return $this->authCodes;
}
/**
* Determines if a client attempting API access is already authorized by the user.
*
* @return bool
*/
public function isAuthorizedClient(User $user)
{
$users = $this->getUsers();
return $users->contains($user);
}
/**
* @return Client
*/
public function addUser(User $users)
{
$this->users[] = $users;
return $this;
}
public function removeUser(User $users): void
{
$this->users->removeElement($users);
}
/**
* @return \Doctrine\Common\Collections\Collection
*/
public function getUsers()
{
return $this->users;
}
/**
* Add Authorization Grant Type.
*/
public function addGrantType(string $grantType): Client
{
$this->allowedGrantTypes[] = $grantType;
return $this;
}
public function getRole(): Role
{
return $this->role;
}
public function setRole(Role $role): void
{
$this->role = $role;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Mautic\ApiBundle\Entity\oAuth2;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\UserBundle\Entity\User;
/**
* @extends CommonRepository<Client>
*/
class ClientRepository extends CommonRepository
{
/**
* @return array
*/
public function getUserClients(User $user)
{
$query = $this->createQueryBuilder($this->getTableAlias());
$query->join('c.users', 'u')
->where($query->expr()->eq('u.id', ':userId'))
->setParameter('userId', $user->getId());
return $query->getQuery()->getResult();
}
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause($q, $filter, [
'c.name',
'c.redirectUris',
]);
}
protected function getDefaultOrder(): array
{
return [
['c.name', 'ASC'],
];
}
public function getTableAlias(): string
{
return 'c';
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Mautic\ApiBundle\Entity\oAuth2;
use Doctrine\ORM\Mapping as ORM;
use FOS\OAuthServerBundle\Model\RefreshToken as BaseRefreshToken;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
class RefreshToken extends BaseRefreshToken
{
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('oauth2_refreshtokens')
->addIndex(['token'], 'oauth2_refresh_token_search');
$builder->createField('id', 'integer')
->makePrimaryKey()
->generatedValue()
->build();
$builder->createManyToOne('client', 'Client')
->addJoinColumn('client_id', 'id', false, false, 'CASCADE')
->build();
$builder->createManyToOne('user', \Mautic\UserBundle\Entity\User::class)
->addJoinColumn('user_id', 'id', false, false, 'CASCADE')
->build();
$builder->createField('token', 'string')
->unique()
->build();
$builder->createField('expiresAt', 'bigint')
->columnName('expires_at')
->nullable()
->build();
$builder->createField('scope', 'string')
->nullable()
->build();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Mautic\ApiBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Symfony\Component\HttpFoundation\Request;
class ApiEntityEvent extends CommonEvent
{
/**
* @param object $entity
*/
public function __construct(
protected $entity,
protected array $entityRequestParameters,
private Request $request,
) {
}
/**
* @return object
*/
public function getEntity()
{
return $this->entity;
}
/**
* @return array
*/
public function getEntityRequestParameters()
{
return $this->entityRequestParameters;
}
/**
* @return Request
*/
public function getRequest()
{
return $this->request;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\Event;
use JMS\Serializer\Exclusion\ExclusionStrategyInterface;
use Symfony\Contracts\EventDispatcher\Event;
final class ApiInitializeEvent extends Event
{
/**
* @param string[] $serializerGroups
* @param ExclusionStrategyInterface[] $exclusionStrategies
*/
public function __construct(
private string $entityClass,
private array $serializerGroups,
private array $exclusionStrategies,
) {
}
public function getEntityClass(): string
{
return $this->entityClass;
}
/**
* @return string[]
*/
public function getSerializerGroups(): array
{
return $this->serializerGroups;
}
public function addSerializerGroup(string $serializerGroup): void
{
$this->serializerGroups[] = $serializerGroup;
}
/**
* @return ExclusionStrategyInterface[]
*/
public function getExclusionStrategies(): array
{
return $this->exclusionStrategies;
}
public function addExclusionStrategy(ExclusionStrategyInterface $exclusionStrategy): void
{
$this->exclusionStrategies[] = $exclusionStrategy;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\Event;
use FOS\RestBundle\Context\Context;
use Mautic\CoreBundle\Event\CommonEvent;
use Symfony\Component\HttpFoundation\Request;
final class ApiSerializationContextEvent extends CommonEvent
{
public function __construct(private Context $context, private Request $request)
{
}
public function getContext(): Context
{
return $this->context;
}
public function setContext(Context $context): void
{
$this->context = $context;
}
public function getRequest(): Request
{
return $this->request;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Mautic\ApiBundle\Event;
use Mautic\ApiBundle\Entity\oAuth2\Client;
use Mautic\CoreBundle\Event\CommonEvent;
class ClientEvent extends CommonEvent
{
private string $apiMode;
public function __construct(Client $client, $isNew = false)
{
$this->apiMode = 'oauth2';
$this->entity = $client;
$this->isNew = $isNew;
}
/**
* Returns the Client entity.
*
* @return Client
*/
public function getClient()
{
return $this->entity;
}
/**
* Returns the api mode.
*/
public function getApiMode(): string
{
return $this->apiMode;
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace Mautic\ApiBundle\EventListener;
use Mautic\ApiBundle\Helper\RequestHelper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Translation\Translator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
class ApiSubscriber implements EventSubscriberInterface
{
public function __construct(
private CoreParametersHelper $coreParametersHelper,
private Translator $translator,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 255],
KernelEvents::RESPONSE => ['onKernelResponse', 0],
];
}
/**
* Check for API requests and throw denied access if API is disabled.
*
* @throws AccessDeniedHttpException
*/
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
// Ignore if not an API request
if (!RequestHelper::isApiRequest($request)) {
return;
}
// Prevent access to API if disabled
$apiEnabled = $this->coreParametersHelper->get('api_enabled');
if (!$apiEnabled) {
$response = new JsonResponse(
[
'errors' => [
[
'message' => $this->translator->trans('mautic.api.error.api.disabled'),
'code' => 403,
'type' => 'api_disabled',
],
],
],
403
);
$event->setResponse($response);
return;
}
// Prevent access via basic auth if it is disabled
$hasBasicAuth = RequestHelper::hasBasicAuth($request);
$basicAuthEnabled = $this->coreParametersHelper->get('api_enable_basic_auth');
if ($hasBasicAuth && !$basicAuthEnabled) {
$response = new JsonResponse(
[
'errors' => [
[
'message' => $this->translator->trans('mautic.api.error.basic.auth.disabled'),
'code' => 403,
'type' => 'access_denied',
],
],
],
403
);
$event->setResponse($response);
}
}
public function onKernelResponse(ResponseEvent $event): void
{
$request = $event->getRequest();
$isApiRequest = RequestHelper::isApiRequest($request);
$hasBasicAuth = RequestHelper::hasBasicAuth($event->getRequest());
// Ignore if this is not an API request
if (!$isApiRequest) {
return;
}
// Ignore if this does not contain an error response
$response = $event->getResponse();
$content = $response->getContent();
if (!str_contains($content, 'error')) {
return;
}
// Ignore if content is not json
if (!$data = json_decode($content, true)) {
return;
}
// Ignore if an error was not found in the JSON response
if (!isset($data['error'])) {
return;
}
// Override api messages with something useful
$type = null;
$error = $data['error'];
if (is_array($error)) {
if (!isset($error['message'])) {
return;
}
// Catch useless oauth errors
$error = $error['message'];
}
switch ($error) {
case 'access_denied':
$type = $error;
$message = $this->translator->trans('mautic.api.auth.error.accessdenied');
if ($hasBasicAuth) {
if ($this->coreParametersHelper->get('api_enable_basic_auth')) {
$message = $this->translator->trans('mautic.api.error.basic.auth.invalid.credentials');
} else {
$message = $this->translator->trans('mautic.api.error.basic.auth.disabled');
}
}
break;
default:
if (isset($data['error_description'])) {
$message = $data['error_description'];
$type = $error;
} elseif ($this->translator->hasId('mautic.api.auth.error.'.$error)) {
$message = $this->translator->trans('mautic.api.auth.error.'.$error);
$type = $error;
}
}
// Message was not overriden so leave as is
if (!isset($message)) {
return;
}
$statusCode = $response->getStatusCode();
$response = new JsonResponse(
[
'errors' => [
[
'message' => $message,
'code' => $response->getStatusCode(),
'type' => $type,
],
],
],
$statusCode
);
$event->setResponse($response);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Mautic\ApiBundle\EventListener;
use Mautic\ApiBundle\ApiEvents;
use Mautic\ApiBundle\Event as Events;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ClientSubscriber implements EventSubscriberInterface
{
public function __construct(
private IpLookupHelper $ipLookupHelper,
private AuditLogModel $auditLogModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
ApiEvents::CLIENT_POST_SAVE => ['onClientPostSave', 0],
ApiEvents::CLIENT_POST_DELETE => ['onClientDelete', 0],
];
}
/**
* Add a client change entry to the audit log.
*/
public function onClientPostSave(Events\ClientEvent $event): void
{
$client = $event->getClient();
if (!$details = $event->getChanges()) {
return;
}
$log = [
'bundle' => 'api',
'object' => 'client',
'objectId' => $client->getId(),
'action' => ($event->isNew()) ? 'create' : 'update',
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
/**
* Add a role delete entry to the audit log.
*/
public function onClientDelete(Events\ClientEvent $event): void
{
$client = $event->getClient();
$log = [
'bundle' => 'api',
'object' => 'client',
'objectId' => $client->deletedId,
'action' => 'delete',
'details' => ['name' => $client->getName()],
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Mautic\ApiBundle\EventListener;
use Mautic\ApiBundle\Form\Type\ConfigType;
use Mautic\ConfigBundle\ConfigEvents;
use Mautic\ConfigBundle\Event\ConfigBuilderEvent;
use Mautic\ConfigBundle\Event\ConfigEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ConfigSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
ConfigEvents::CONFIG_ON_GENERATE => ['onConfigGenerate', 0],
ConfigEvents::CONFIG_PRE_SAVE => ['onConfigSave', 0],
];
}
public function onConfigGenerate(ConfigBuilderEvent $event): void
{
$event->addForm([
'bundle' => 'ApiBundle',
'formAlias' => 'apiconfig',
'formType' => ConfigType::class,
'formTheme' => '@MauticApi/FormTheme/Config/_config_apiconfig_widget.html.twig',
'parameters' => $event->getParametersFromConfig('MauticApiBundle'),
]);
}
public function onConfigSave(ConfigEvent $event): void
{
// Symfony craps out with integer for firewall settings
$data = $event->getConfig('apiconfig');
if (isset($data['api_enable_basic_auth'])) {
$data['api_enable_basic_auth'] = (bool) $data['api_enable_basic_auth'];
$event->setConfig($data, 'apiconfig');
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Mautic\ApiBundle\EventListener;
use Doctrine\ORM\EntityManager;
use FOS\OAuthServerBundle\Event\OAuthEvent;
use Mautic\ApiBundle\Entity\oAuth2\Client;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Contracts\Translation\TranslatorInterface;
class PreAuthorizationEventListener
{
public function __construct(
private EntityManager $em,
private CorePermissions $mauticSecurity,
private TranslatorInterface $translator,
) {
}
/**
* @throws AccessDeniedException
*/
public function onPreAuthorizationProcess(OAuthEvent $event): void
{
if ($user = $this->getUser($event)) {
// check to see if user has api access
if (!$this->mauticSecurity->isGranted('api:access:full')) {
throw new AccessDeniedException($this->translator->trans('mautic.core.error.accessdenied', [], 'flashes'));
}
$client = $event->getClient();
if ($client instanceof Client) {
$event->setAuthorizedClient(
$client->isAuthorizedClient($user)
);
}
}
}
public function onPostAuthorizationProcess(OAuthEvent $event): void
{
$client = $event->getClient();
if ($event->isAuthorizedClient() && $client instanceof Client) {
if ($user = $this->getUser($event)) {
$client->addUser($user);
$this->em->persist($client);
$this->em->flush();
}
}
}
/**
* @return mixed
*/
protected function getUser(OAuthEvent $event)
{
return $this->em->getRepository(\Mautic\UserBundle\Entity\User::class)->findOneByUsername($event->getUser()->getUserIdentifier());
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\EventListener;
use Mautic\ApiBundle\Model\ClientModel;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\DTO\GlobalSearchFilterDTO;
use Mautic\CoreBundle\Event as MauticEvents;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\GlobalSearch;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SearchSubscriber implements EventSubscriberInterface
{
public function __construct(
private ClientModel $apiClientModel,
private CorePermissions $security,
private GlobalSearch $globalSearch,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::GLOBAL_SEARCH => ['onGlobalSearch', 0],
CoreEvents::BUILD_COMMAND_LIST => ['onBuildCommandList', 0],
];
}
public function onGlobalSearch(MauticEvents\GlobalSearchEvent $event): void
{
$filterDTO = new GlobalSearchFilterDTO($event->getSearchString());
$results = $this->globalSearch->performSearch(
$filterDTO,
$this->apiClientModel,
'@MauticApi/SubscribedEvents/Search/global.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.api.client.menu.index', $results);
}
}
public function onBuildCommandList(MauticEvents\CommandListEvent $event): void
{
if ($this->security->isGranted('api:clients:view')) {
$event->addCommands(
'mautic.api.client.header.index',
$this->apiClientModel->getCommandList()
);
}
}
}

View File

@@ -0,0 +1,170 @@
<?php
namespace Mautic\ApiBundle\Form\Type;
use Mautic\ApiBundle\Entity\oAuth2\Client;
use Mautic\ApiBundle\Form\Validator\Constraints\OAuthCallback;
use Mautic\CoreBundle\Form\DataTransformer as Transformers;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @extends AbstractType<Client>
*/
class ClientType extends AbstractType
{
public function __construct(
private RequestStack $requestStack,
private TranslatorInterface $translator,
private ValidatorInterface $validator,
private RouterInterface $router,
) {
}
/**
* @return bool|mixed
*/
private function getApiMode()
{
return $this->requestStack->getCurrentRequest()->get(
'api_mode',
$this->requestStack->getSession()->get('mautic.client.filter.api_mode', 'oauth2')
);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$apiMode = $this->getApiMode();
$builder->addEventSubscriber(new CleanFormSubscriber([]));
$builder->addEventSubscriber(new FormExitSubscriber('api.client', $options));
if (!$options['data']->getId()) {
$builder->add(
'api_mode',
ChoiceType::class,
[
'mapped' => false,
'label' => 'mautic.api.client.form.auth_protocol',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'onchange' => 'Mautic.refreshApiClientForm(\''.$this->router->generate('mautic_client_action', ['objectAction' => 'new']).'\', this)',
],
'choices' => [
'OAuth 2' => 'oauth2',
],
'required' => false,
'placeholder' => false,
'data' => $apiMode,
]
);
}
$builder->add(
'name',
TextType::class,
[
'label' => 'mautic.core.name',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$arrayStringTransformer = new Transformers\ArrayStringTransformer();
$builder->add(
$builder->create(
'redirectUris',
TextType::class,
[
'label' => 'mautic.api.client.redirecturis',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.api.client.form.help.requesturis',
],
]
)
->addViewTransformer($arrayStringTransformer)
);
$builder->add(
'publicId',
TextType::class,
[
'label' => 'mautic.api.client.form.clientid',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'disabled' => true,
'required' => false,
'mapped' => false,
'data' => $options['data']->getPublicId(),
]
);
$builder->add(
'secret',
TextType::class,
[
'label' => 'mautic.api.client.form.clientsecret',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'disabled' => true,
'required' => false,
]
);
$builder->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event): void {
$form = $event->getForm();
$data = $event->getData();
if ($form->has('redirectUris')) {
foreach ($data->getRedirectUris() as $uri) {
$urlConstraint = new OAuthCallback();
$urlConstraint->message = $this->translator->trans(
'mautic.api.client.redirecturl.invalid',
['%url%' => $uri],
'validators'
);
$errors = $this->validator->validate($uri, $urlConstraint);
foreach ($errors as $error) {
$form['redirectUris']->addError(new FormError($error->getMessage()));
}
}
}
}
);
$builder->add('buttons', FormButtonsType::class);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$dataClass = Client::class;
$resolver->setDefaults(
[
'data_class' => $dataClass,
]
);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Mautic\ApiBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<mixed>
*/
class ConfigType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'api_enabled',
YesNoButtonGroupType::class,
[
'label' => 'mautic.api.config.form.api.enabled',
'data' => isset($options['data']['api_enabled']) && (bool) $options['data']['api_enabled'],
'help' => 'mautic.api.config.form.api.enabled.help',
]
);
$builder->add(
'api_enable_basic_auth',
YesNoButtonGroupType::class,
[
'label' => 'mautic.api.config.form.api.basic_auth_enabled',
'data' => isset($options['data']['api_enable_basic_auth']) && (bool) $options['data']['api_enable_basic_auth'],
'attr' => [
'tooltip' => 'mautic.api.config.form.api.basic_auth.tooltip',
],
]
);
$builder->add(
'api_oauth2_access_token_lifetime',
NumberType::class,
[
'label' => 'mautic.api.config.form.api.oauth2_access_token_lifetime',
'attr' => [
'tooltip' => 'mautic.api.config.form.api.oauth2_access_token_lifetime.tooltip',
'class' => 'form-control',
'data-show-on' => '{"config_apiconfig_api_enabled_1":"checked"}',
],
'constraints' => [
new NotBlank(
[
'message' => 'mautic.core.value.required',
]
),
],
]
);
$builder->add(
'api_oauth2_refresh_token_lifetime',
NumberType::class,
[
'label' => 'mautic.api.config.form.api.oauth2_refresh_token_lifetime',
'attr' => [
'tooltip' => 'mautic.api.config.form.api.oauth2_refresh_token_lifetime.tooltip',
'class' => 'form-control',
'data-show-on' => '{"config_apiconfig_api_enabled_1":"checked"}',
],
'constraints' => [
new NotBlank(
[
'message' => 'mautic.core.value.required',
]
),
],
]
);
}
public function getBlockPrefix(): string
{
return 'apiconfig';
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Mautic\ApiBundle\Form\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
class OAuthCallback extends Constraint
{
public $message = 'The callback URL is invalid.';
public function validatedBy(): string
{
return OAuthCallbackValidator::class;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Mautic\ApiBundle\Form\Validator\Constraints;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class OAuthCallbackValidator extends ConstraintValidator
{
public const PATTERN = '~^[0-9a-z].*://(.*?)(:[0-9]+)?(/?|/\S+)$~ixu';
/**
* @param mixed $value
*/
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof OAuthCallback) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__.'\OAuthCallback');
}
if (null === $value || '' === $value) {
return;
}
if (!is_scalar($value) && !(is_object($value) && method_exists($value, '__toString'))) {
throw new UnexpectedTypeException($value, 'string');
}
$value = (string) $value;
if (!preg_match(static::PATTERN, $value)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $this->formatValue($value))
->addViolation();
}
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace Mautic\ApiBundle\Helper;
use Mautic\CoreBundle\Helper\CsvHelper;
class BatchIdToEntityHelper
{
/**
* @var array
*/
private $ids = [];
private array $originalKeys = [];
private array $errors = [];
private bool $isAssociative = false;
/**
* @param string $idKey
*/
public function __construct(
array $parameters,
private $idKey = 'id',
) {
$this->extractIds($parameters);
}
public function hasIds(): bool
{
return !empty($this->ids);
}
/**
* @return array
*/
public function getIds()
{
return $this->ids;
}
public function hasErrors(): bool
{
return !empty($this->errors);
}
/**
* @return array
*/
public function getErrors()
{
return $this->errors;
}
/**
* Reorder the entities based on the original keys
* BC allowed a request to have associative keys (don't ask why; yes it's terrible implementation but we're keeping BC here)
* The issue this solves is the response should match the format given by the request. If the request had associative keys, the response
* will return with associative keys (json object). If the request was a sequential numeric array starting with 0, the response will
* be a simple array (json array).
*/
public function orderByOriginalKey(array $entities): array
{
if (!$this->isAssociative) {
// The request was keyed by sequential numbers starting with 0
return array_values($entities);
}
// Ensure entities are keyed by ID in order to find the original keys assuming that some entities are missing if the ID was not found
$entitiesKeyedById = [];
foreach ($entities as $entity) {
$entitiesKeyedById[$entity->getId()] = $entity;
}
$orderedEntities = [];
foreach ($this->ids as $key => $id) {
if (!isset($entitiesKeyedById[$id])) {
$hasPreviousId = array_filter(
$entities,
fn ($entity) => $id == $entity->getPreviousId()
);
if ($hasPreviousId) {
$orderedEntities[$key] = array_shift($hasPreviousId);
}
continue;
}
$originalKey = $this->originalKeys[$key];
$orderedEntities[$originalKey] = $entitiesKeyedById[$id];
}
return $orderedEntities;
}
private function extractIds(array $parameters): void
{
$this->ids = [];
if (isset($parameters['ids'])) {
$this->extractIdsFromIdKey($parameters['ids']);
return;
}
$this->extractIdsFromParams($parameters);
}
/**
* @param mixed $ids
*/
private function extractIdsFromIdKey($ids): void
{
// ['ids' => [1,2,3]]
if (is_array($ids)) {
$this->isAssociative = $this->isAssociativeArray($ids);
$this->ids = array_values($ids);
$this->originalKeys = array_keys($ids);
return;
}
// ['ids' => '1,2,3'] OR ['ids' => '1']
if (str_contains($ids, ',') || is_numeric($ids)) {
$this->ids = CsvHelper::strGetCsv($ids);
$this->originalKeys = array_keys($this->ids);
$this->isAssociative = false;
return;
}
// Couldn't parse the 'ids' key; not throwing an exception in order to keep BC with
// the old CommonApiController code and the use of a foreach in extractIdsFromParams
$this->errors[] = 'mautic.api.call.id_missing';
}
private function extractIdsFromParams(array $parameters): void
{
$this->isAssociative = $this->isAssociativeArray($parameters);
$this->originalKeys = array_keys($parameters);
// [1,2,3]
$firstKey = array_key_first($parameters);
if (!is_array($parameters[$firstKey])) {
$this->ids = array_values($parameters);
return;
}
// [ ['id' => 1, 'foo' => 'bar'], ['id' => 2, 'bar' => 'foo'] ]
foreach ($parameters as $key => $params) {
// Missing id column key in the array; terrible but keep BC
if (!isset($params[$this->idKey])) {
$this->errors[$key] = 'mautic.api.call.id_missing';
continue;
}
$this->ids[] = $params[$this->idKey];
}
}
private function isAssociativeArray(array $array): bool
{
if (empty($array)) {
return false;
}
$firstKey = array_key_first($array);
return array_keys($array) !== range(0, count($array) - 1) && 0 !== $firstKey;
}
public function setIsAssociative(bool $isAssociative): void
{
$this->isAssociative = $isAssociative;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Mautic\ApiBundle\Helper;
use Doctrine\ORM\Tools\Pagination\Paginator;
class EntityResultHelper
{
/**
* @param array<mixed>|Paginator<mixed> $results
* @param callable|null $callback
*
* @return array<mixed>|\ArrayObject<int,mixed>
*/
public function getArray($results, $callback = null)
{
$entities = [];
// we have to convert them from paginated proxy functions to entities in order for them to be
// returned by the serializer/rest bundle
foreach ($results as $key => $entityRow) {
$entities[$key] = $this->getEntityData($entityRow);
if (is_callable($callback)) {
$callback($entities[$key]);
}
}
// solving array/object discrepancy for empty values
if ($this->isKeyedById($results) && empty($entities)) {
$entities = new \ArrayObject();
}
return $entities;
}
/**
* @param mixed $entityRow
*
* @return mixed
*/
private function getEntityData($entityRow)
{
if (is_array($entityRow) && isset($entityRow[0])) {
return $this->getDataForArray($entityRow);
}
return $entityRow;
}
/**
* @param array $array
*
* @return mixed
*/
private function getDataForArray($array)
{
if (is_object($array[0])) {
return $this->getDataForObject($array);
}
return $array[0];
}
/**
* @param object $object
*
* @return mixed
*/
private function getDataForObject($object)
{
foreach ($object as $key => $value) {
if (0 === $key) {
continue;
}
$object[0]->$key = $value;
}
return $object[0];
}
/**
* @param array<mixed>|Paginator<mixed> $results
*/
private function isKeyedById($results): bool
{
return !$results instanceof Paginator;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Mautic\ApiBundle\Helper;
use Symfony\Component\HttpFoundation\Request;
class RequestHelper
{
public static function hasBasicAuth(Request $request): bool
{
return str_starts_with(strtolower((string) $request->headers->get('Authorization')), 'basic');
}
public static function isApiRequest(Request $request): bool
{
$requestUrl = $request->getRequestUri();
// Check if /oauth or /api
$isApiRequest = (str_contains($requestUrl, '/oauth') || str_contains($requestUrl, '/api'));
defined('MAUTIC_API_REQUEST') or define('MAUTIC_API_REQUEST', $isApiRequest);
return $isApiRequest;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Mautic\ApiBundle;
use Mautic\ApiBundle\DependencyInjection\Compiler\SerializerPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MauticApiBundle extends Bundle
{
public function build(ContainerBuilder $container): void
{
parent::build($container);
$container->addCompilerPass(new SerializerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace Mautic\ApiBundle\Model;
use Doctrine\ORM\EntityManager;
use Mautic\ApiBundle\ApiEvents;
use Mautic\ApiBundle\Entity\oAuth2\Client;
use Mautic\ApiBundle\Event\ClientEvent;
use Mautic\ApiBundle\Form\Type\ClientType;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\UserBundle\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends FormModel<Client>
*/
class ClientModel extends FormModel implements GlobalSearchInterface
{
/**
* @var string
*/
public const API_MODE_OAUTH2 = 'oauth2';
private ?string $apiMode = null;
private const DEFAULT_API_MODE = 'oauth2';
public function __construct(
private RequestStack $requestStack,
EntityManager $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
private function getApiMode(): string
{
if (null !== $this->apiMode) {
return $this->apiMode;
}
if (null !== $request = $this->requestStack->getCurrentRequest()) {
return $request->get('api_mode', $request->getSession()->get('mautic.client.filter.api_mode', self::DEFAULT_API_MODE));
}
return self::DEFAULT_API_MODE;
}
public function setApiMode($apiMode): void
{
$this->apiMode = $apiMode;
}
public function getRepository(): \Mautic\ApiBundle\Entity\oAuth2\ClientRepository
{
return $this->em->getRepository(Client::class);
}
public function getPermissionBase(): string
{
return 'api:clients';
}
/**
* @throws MethodNotAllowedHttpException
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof Client) {
throw new MethodNotAllowedHttpException(['Client']);
}
$params = (!empty($action)) ? ['action' => $action] : [];
return $formFactory->create(ClientType::class, $entity, $params);
}
public function getEntity($id = null): ?Client
{
if (null === $id) {
return 'oauth2' === $this->getApiMode() ? new Client() : null;
}
return parent::getEntity($id);
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof Client) {
throw new MethodNotAllowedHttpException(['Client']);
}
switch ($action) {
case 'post_save':
$name = ApiEvents::CLIENT_POST_SAVE;
break;
case 'post_delete':
$name = ApiEvents::CLIENT_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new ClientEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
}
return null;
}
/**
* @return array
*/
public function getUserClients(User $user)
{
return $this->getRepository()->getUserClients($user);
}
/**
* @throws MethodNotAllowedHttpException
*/
public function revokeAccess($entity): void
{
if (!$entity instanceof Client) {
throw new MethodNotAllowedHttpException(['Client']);
}
// remove the user from the client
if ('oauth2' === $this->getApiMode()) {
$entity->removeUser($this->userHelper->getUser());
$this->saveEntity($entity);
} else {
$this->getRepository()->deleteAccessTokens($entity, $this->userHelper->getUser());
}
}
}

View File

@@ -0,0 +1,20 @@
{% extends '@MauticUser/Security/base.html.twig' %}
{% block headerTitle %}mautic.api.oauth.header{% endblock %}
{% set name = client.getName() %}
{% set msg = name is empty ? 'mautic.api.oauth.clientnoname'|trans : 'mautic.api.oauth.clientwithname'|trans({'name' : name}) %}
{% block content %}
<h4 class="mb-lg">{{ msg|purify }} </h4>
<form class="form-login text-center" role="form" name="fos_oauth_server_authorize_form" action="{{ path('fos_oauth_server_authorize') }}" method="post">
<input type="submit" class="btn btn-primary btn-accept" name="accepted" value="{{ 'mautic.api.oauth.accept'|trans }}" />
<input type="submit" class="btn btn-danger btn-deny" name="rejected" value="{{ 'mautic.api.oauth.deny'|trans }}" />
{{ form_row(form.client_id) }}
{{ form_row(form.response_type) }}
{{ form_row(form.redirect_uri) }}
{{ form_row(form.state) }}
{{ form_row(form.scope) }}
{{ form_rest(form) }}
</form>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% block content %}
<table class="table table-hover">
<thead>
<tr>
<th style="width: 50px;"></th>
<th>{{ 'mautic.core.name'|trans }}</th>
</tr>
</thead>
<tbody>
{% for k in clients %}
<tr>
<td>
{{- include('@MauticCore/Helper/confirm.html.twig', {
'btnClass' : 'btn btn-danger btn-xs',
'message' : 'mautic.api.client.form.confirmrevoke'|trans({'%name%' : k.getName()}),
'confirmText' : 'mautic.api.client.form.revoke'|trans,
'confirmAction' : path('mautic_client_action', {'objectAction' : 'revoke', 'objectId' : k.getId()}),
'iconClass' : 'ri-delete-bin-line',
'btnText' : 'mautic.api.client.form.revoke'|trans,
}) -}}
</td>
<td>{{ k.getName() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}client{% endblock %}
{% set id = form.vars.data.getId() %}
{% if id is empty %}
{% set header = 'mautic.api.client.header.edit'|trans({'%name%' : form.vars.data.getName()}) %}
{% else %}
{% set header = 'mautic.api.client.header.new'|trans %}
{% endif %}
{% block headerTitle %}{{ header }}{% endblock %}
{% block content %}
<div class="col-sm-9 pt-lg">
<div class="row">
<div class="col-xs-12">
{{ form(form) }}
</div>
{{ include('@MauticWebhook/Modules/webhooks_card.html.twig', ignore_missing=true) }}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,143 @@
{# //Check to see if the entire page should be displayed or just main content #}
{% set isIndex = tmpl == 'index' ? true : false %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block headerTitle %}{% trans %}mautic.api.client.header.index{% endtrans %}{% endblock %}
{% block mauticContent %}client{% endblock %}
{% block actions %}
{{- include('@MauticCore/Helper/page_actions.html.twig',
{
'templateButtons' : {
'new' : permissions.create,
},
'routeBase' : 'client',
'langVar' : 'api.client',
}
) -}}
{% endblock %}
{% block content %}
{% if isIndex %}
<div id="page-list-wrapper" class="{% if items|length > 0 or searchValue is not empty %}panel {% endif %}panel-default">
{{- include('@MauticCore/Helper/list_toolbar.html.twig', {
'searchValue' : searchValue,
'searchHelp' : 'mautic.api.client.help.searchcommands',
'filters' : filters,
}) -}}
<div class="page-list">
{{ block('listResults') }}
</div>
</div>
{% else %}
{{ block('listResults') }}
{% endif %}
{% endblock %}
{% block listResults %}
{% if items|length > 0 %}
<div class="table-responsive panel-collapse pull out page-list">
<table class="table table-hover client-list">
<thead>
<tr>
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'checkall' : false,
'text' : null,
'target' : '.client-list',
'action' : currentRoute,
'routeBase' : 'client',
'templateButtons' : {},
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'client',
'orderBy' : 'c.name',
'text' : 'mautic.core.name',
'default' : 'true',
'class' : 'col-client-name',
}
) -}}
<th class="visible-md visible-lg col-client-publicid">{{ 'mautic.api.client.thead.publicid'|trans }}</th>
<th class="visible-md visible-lg col-client-secret">{{ 'mautic.api.client.thead.secret'|trans }}</th>
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'client',
'orderBy' : 'c.id',
'text' : 'mautic.core.id',
'class' : 'visible-md visible-lg col-client-id',
}
) -}}
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{{- include(
'@MauticCore/Helper/list_actions.html.twig',
{
'item' : item,
'templateButtons' : {
'edit' : permissions.edit,
'delete' : permissions.delete,
},
'routeBase' : 'client',
'langVar' : 'api.client',
'pull' : 'left',
}
) -}}
</td>
<td>
{{ item.getName() }}
</td>
<td class="visible-md visible-lg">
<input onclick="this.setSelectionRange(0, this.value.length);" type="text" class="form-control" readonly value="{{ item.getPublicId() }}"/>
</td>
<td class="visible-md visible-lg">
<input onclick="this.setSelectionRange(0, this.value.length);" type="text" class="form-control" readonly value="{{ item.getSecret() }}"/>
</td>
<td class="visible-md visible-lg">{{ item.getId() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
{{- include('@MauticCore/Helper/pagination.html.twig', {
'totalItems' : items|length,
'page' : page,
'limit' : limit,
'menuLinkId' : 'mautic_client_index',
'baseUrl' : path('mautic_client_index'),
'sessionVar' : 'client',
'tmpl' : tmpl,
}) -}}
</div>
</div>
{% else %}
{% if searchValue is not empty %}
{{- include('@MauticCore/Helper/noresults.html.twig') -}}
{% else %}
<div class="mt-80 col-md-offset-2 col-lg-offset-3 col-md-8 col-lg-5 height-auto">
{% set childContainer %}
<div class="mt-32 mb-md">
{% include '@MauticCore/Components/pictogram.html.twig' with {
'pictogram': 'api',
'size': '80'
} %}
</div>
{% endset %}
{{ include('@MauticCore/Components/content-block.html.twig', {
heading: 'mautic.api.client.contentblock.heading',
subheading: 'mautic.api.client.contentblock.subheading',
copy: 'mautic.api.client.contentblock.copy',
childContainer: childContainer,
}) }}
</div>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% block _config_apiconfig_widget %}
<h4 class="fw-sb mt-48 mb-xs">{{ 'mautic.config.tab.apiconfig'|trans }}</h4>
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.apiconfig.description'|trans }}</div>
<div class="row">
<div class="panel panel-default mb-md">
<div class="panel-body">
<div class="row">
{% for f in form.children %}
<div class="col-xs-12">
{{ form_row(f) }}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends '@MauticUser/Security/base.html.twig' %}
{% block headerTitle %}{{ 'mautic.api.oauth.header'|trans }}{% endblock %}
{% block content %}
<form class="form-group login-form" name="login" data-toggle="ajax" role="form" action="{{ path(route) }}" method="post">
<div class="input-group mb-md">
<span class="input-group-addon"><i class="ri-user-6-fill"></i></span>
<label for="username" class="sr-only">{{ 'mautic.user.auth.form.loginusername'|trans }}</label>
<input type="text" id="username" name="_username" class="form-control input-lg" value="{{ last_username }}" required autofocus placeholder="{{ 'mautic.user.auth.form.loginusername'|trans }}" />
</div>
<div class="input-group mb-md">
<span class="input-group-addon"><i class="ri-key-2-line"></i></span>
<label for="password" class="sr-only">{{ 'mautic.core.password'|trans }}:</label>
<input type="password" id="password" name="_password" class="form-control input-lg" required placeholder="{{ 'mautic.core.password'|trans }}" />
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit">{{ 'mautic.user.auth.form.loginbtn'|trans }}</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% block content %}
{% if showMore is defined and showMore is not empty %}
<a href="{{ url('mautic_client_index', {'search' : searchString}) }}" data-toggle="ajax">
<span>{{ 'mautic.core.search.more'|trans({'%count%' : remaining}) }}</span>
</a>
{% else %}
<a href="{{ url('mautic_client_action', {'objectAction' : 'edit', 'objectId' : item.getId()}) }}">
{{ item.getName() }}
</a>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,70 @@
<?php
namespace Mautic\ApiBundle\Security\Permissions;
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
use Mautic\UserBundle\Form\Type\PermissionListType;
use Symfony\Component\Form\FormBuilderInterface;
class ApiPermissions extends AbstractPermissions
{
public function __construct($params)
{
parent::__construct($params);
$this->permissions = [
'access' => [
'full' => 1024,
],
];
$this->addStandardPermissions('clients', false);
}
public function getName(): string
{
return 'api';
}
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
{
$builder->add(
'api:access',
PermissionListType::class,
[
'choices' => [
'mautic.api.permissions.granted' => 'full',
],
'label' => 'mautic.api.permissions.apiaccess',
'data' => (!empty($data['access']) ? $data['access'] : []),
'bundle' => 'api',
'level' => 'access',
]
);
$this->addStandardFormFields('api', 'clients', $builder, $data, false);
}
public function getValue($name, $perm)
{
// ensure api is enabled system wide
if (empty($this->params['api_enabled'])) {
return 0;
}
return parent::getValue($name, $perm);
}
public function isEnabled(): bool
{
return !empty($this->params['api_enabled']);
}
protected function getSynonym($name, $level)
{
if ('access' == $name && 'granted' == $level) {
return [$name, 'full'];
}
return parent::getSynonym($name, $level);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Mautic\ApiBundle\Security\Voter;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class ApiPermissionVoter extends Voter
{
public function __construct(private CorePermissions $security)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
// Support Mautic permission format like 'focus:items:viewown'
return str_contains($attribute, ':');
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}
// Use Mautic's security system to check permissions
return $this->security->isGranted($attribute);
}
}

View File

@@ -0,0 +1,316 @@
<?php
namespace Mautic\ApiBundle\Serializer\Driver;
use JMS\Serializer\Metadata\ClassMetadata;
use JMS\Serializer\Metadata\PropertyMetadata;
use Metadata\ClassMetadata as BaseClassMetadata;
use Metadata\Driver\DriverInterface;
class ApiMetadataDriver implements DriverInterface
{
private ?ClassMetadata $metadata = null;
/**
* @var PropertyMetadata[]
*/
private array $properties = [];
private string $groupPrefix = '';
private string $defaultVersion = '1.0';
private ?string $currentPropertyName = null;
/**
* @throws \ReflectionException
*/
public function loadMetadataForClass(\ReflectionClass $class): ?BaseClassMetadata
{
if ($class->hasMethod('loadApiMetadata')) {
$this->metadata = new ClassMetadata($class->getName());
$class->getMethod('loadApiMetadata')->invoke(null, $this);
$metadata = $this->metadata;
$this->resetDefaults();
return $metadata;
} else {
return new ClassMetadata($class->getName());
}
}
private function resetDefaults(): void
{
$this->metadata = null;
$this->properties = [];
$this->defaultVersion = '1.0';
$this->groupPrefix = '';
}
/**
* Set the root (base key).
*
* @return $this
*/
public function setRoot($root)
{
$this->metadata->xmlRootName = $root;
return $this;
}
/**
* Set prefix for the List and Details groups.
*
* @return $this
*/
public function setGroupPrefix($name)
{
$this->groupPrefix = $name;
return $this;
}
/**
* Set the default version for the properties if different than 1.0.
*
* @return $this
*/
public function setDefaultVersion(string $version)
{
$this->defaultVersion = $version;
return $this;
}
/**
* Create a new property.
*
* @return $this
*/
public function createProperty($name)
{
if (!isset($this->properties[$name])) {
$this->properties[$name] = new PropertyMetadata($this->metadata->name, $name);
}
$this->currentPropertyName = $name;
return $this;
}
/**
* Add property and set default version and Details group.
*
* @param bool $useGetter
*
* @return $this
*/
public function addProperty($name, $serializedName = null, $useGetter = false)
{
if (empty($name)) {
return $this;
}
$this->createProperty($name);
if ($useGetter && !$this->properties[$name]->getter) {
$this->properties[$name]->getter = 'get'.ucfirst($name);
}
$this->properties[$name]->serializedName = $serializedName ?? $name;
if ($this->defaultVersion) {
// Set the default version
$this->setSinceVersion($this->defaultVersion);
}
$this->addGroup($this->groupPrefix.'Details');
return $this;
}
/**
* Create properties.
*
* @param bool|false $addToListGroup
* @param bool|false $useGetter
*
* @return $this
*/
public function addProperties(array $properties, $addToListGroup = false, $useGetter = false)
{
foreach ($properties as $prop) {
if (!empty($prop)) {
$serializedName = null;
if (is_array($prop)) {
[$prop, $serializedName] = $prop;
}
$this->addProperty($prop, $serializedName, $useGetter);
if ($addToListGroup) {
$this->inListGroup();
}
}
}
return $this;
}
/**
* Create properties and add to the List group.
*
* @return $this
*/
public function addListProperties(array $properties)
{
$this->addProperties($properties, true);
return $this;
}
/**
* @return $this
*/
public function setSinceVersion($version, $property = null)
{
if (null === $property) {
$property = $this->getCurrentPropertyName();
}
$this->properties[$property]->sinceVersion = $version;
return $this;
}
/**
* @return $this
*/
public function setUntilVersion($version, $property = null)
{
if (null === $property) {
$property = $this->getCurrentPropertyName();
}
$this->properties[$property]->untilVersion = $version;
return $this;
}
/**
* @return $this
*/
public function setSerializedName($name, $property = null)
{
if (null === $property) {
$property = $this->getCurrentPropertyName();
}
$this->properties[$property]->serializedName = $name;
return $this;
}
/**
* Set the groups a property belongs to.
*
* @return $this
*/
public function setGroups($groups, $property = null)
{
if (!is_array($groups)) {
$groups = [$groups];
}
if (null === $property) {
$property = $this->getCurrentPropertyName();
}
$this->properties[$property]->groups = $groups;
return $this;
}
/**
* Add a group the property belongs to.
*
* @param mixed $property
*
* @return $this
*/
public function addGroup($group, $property = null)
{
if (true === $property) {
foreach ($this->properties as $prop => $metadata) {
$this->addGroup($group, $prop);
}
} else {
if (null === $property) {
$property = $this->getCurrentPropertyName();
}
$this->properties[$property]->groups[] = $group;
}
return $this;
}
/**
* Add property to the List group.
*
* @return $this
*/
public function inListGroup()
{
$this->properties[$this->currentPropertyName]->groups[] =
$this->groupPrefix.'List';
return $this;
}
/**
* Set max depth for the property if an association.
*
* @return $this
*/
public function setMaxDepth($depth, $property = null)
{
if (null === $property) {
$property = $this->getCurrentPropertyName();
}
$this->properties[$property]->maxDepth = (int) $depth;
return $this;
}
/**
* Push the properties into ClassMetadata.
*/
public function build(): void
{
foreach ($this->properties as $prop) {
$this->metadata->addPropertyMetadata($prop);
}
$this->currentPropertyName = null;
$this->properties = [];
}
/**
* @return string
*
* @throws \Exception
*/
protected function getCurrentPropertyName()
{
if (empty($this->currentPropertyName)) {
throw new \Exception('Current property is not set');
}
return $this->currentPropertyName;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Mautic\ApiBundle\Serializer\Exclusion;
use JMS\Serializer\Context;
use JMS\Serializer\Exclusion\ExclusionStrategyInterface;
use JMS\Serializer\Metadata\ClassMetadata;
use JMS\Serializer\Metadata\PropertyMetadata;
/**
* Exclude specific fields at a specific level.
*/
class FieldExclusionStrategy implements ExclusionStrategyInterface
{
private int $level;
/**
* @param int $level
* @param string|null $path
*/
public function __construct(
private array $fields,
$level = 3,
private $path = null,
) {
$this->level = (int) $level;
}
public function shouldSkipClass(ClassMetadata $metadata, Context $navigatorContext): bool
{
return false;
}
public function shouldSkipProperty(PropertyMetadata $property, Context $navigatorContext): bool
{
if ($this->path) {
$path = implode('.', $navigatorContext->getCurrentPath());
if ($path !== $this->path) {
return false;
}
}
$name = $property->serializedName ?: $property->name;
if (!in_array($name, $this->fields)) {
return false;
}
// children of children or parents of chidlren will be more than 3 levels deep
if ($navigatorContext->getDepth() <= $this->level) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Mautic\ApiBundle\Serializer\Exclusion;
use JMS\Serializer\Context;
use JMS\Serializer\Exclusion\ExclusionStrategyInterface;
use JMS\Serializer\Metadata\ClassMetadata;
use JMS\Serializer\Metadata\PropertyMetadata;
/**
* Include specific fields at a specific level.
*/
class FieldInclusionStrategy implements ExclusionStrategyInterface
{
private int $level;
/**
* @param int $level
*/
public function __construct(
private array $fields,
$level = 3,
private $path = null,
) {
$this->level = (int) $level;
}
public function shouldSkipClass(ClassMetadata $metadata, Context $navigatorContext): bool
{
return false;
}
public function shouldSkipProperty(PropertyMetadata $property, Context $navigatorContext): bool
{
if ($this->path) {
$path = implode('.', $navigatorContext->getCurrentPath());
if ($path !== $this->path) {
return false;
}
}
$name = $property->serializedName ?: $property->name;
if (in_array($name, $this->fields)) {
return false;
}
// children of children or parents of chidlren will be more than 3 levels deep
if ($navigatorContext->getDepth() <= $this->level) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Mautic\ApiBundle\Serializer\Exclusion;
/**
* Only include the first level of a children/parent of an entity that relates to itself.
*/
class ParentChildrenExclusionStrategy extends FieldExclusionStrategy
{
/**
* @param int $level
*/
public function __construct($level = 3)
{
parent::__construct(
[
'parent',
'children',
],
$level
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Mautic\ApiBundle\Serializer\Exclusion;
/**
* Only include FormEntity properties for the top level entity and not the associated entities.
*/
class PublishDetailsExclusionStrategy extends FieldExclusionStrategy
{
public function __construct()
{
parent::__construct(
[
'isPublished',
'dateAdded',
'createdBy',
'dateModified',
'modifiedBy',
'checkedOut',
'checkedOutBy',
],
1
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Put;
use ApiPlatform\State\ProcessorInterface;
use Doctrine\ORM\EntityManagerInterface;
/**
* Custom processor for PUT operations to ensure entities are replaced instead of created.
*
* This processor decorates the default persist processor and intercepts PUT operations
* to load existing entities from the database and completely replace them with incoming data,
* following proper HTTP PUT semantics. It applies globally to all API Platform entities.
*/
final class PutProcessor implements ProcessorInterface
{
public function __construct(
private ProcessorInterface $persistProcessor,
private EntityManagerInterface $entityManager,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
// Only handle PUT operations with an ID in the URI and valid entity data
if (!$operation instanceof Put || !isset($uriVariables['id']) || !is_object($data)) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// Load the existing entity from the database
$existingEntity = $this->entityManager->find($data::class, $uriVariables['id']);
if (null === $existingEntity) {
// Entity doesn't exist, let the default processor create it
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// For PUT operations, we want to replace the existing entity completely
// The incoming $data contains the new state that should replace the existing entity
// Following HTTP PUT semantics: completely replace the resource
$this->mergeEntityData($data, $existingEntity, $data::class);
// Persist the changes
$this->entityManager->persist($existingEntity);
$this->entityManager->flush();
return $existingEntity;
}
/**
* Replace data from the incoming entity into the existing entity.
*
* For PUT operations, we completely replace the resource with the provided data,
* including setting fields to null if they're not provided in the request.
*
* @param class-string $entityClass
*/
private function mergeEntityData(object $sourceEntity, object $targetEntity, string $entityClass): void
{
// Get the entity metadata to know which properties to update
$metadata = $this->entityManager->getClassMetadata($entityClass);
// Update regular fields
foreach ($metadata->getFieldNames() as $fieldName) {
if (!$metadata->isIdentifier($fieldName)) {
$this->updateEntityField($sourceEntity, $targetEntity, $fieldName);
}
}
// Update associations
foreach ($metadata->getAssociationNames() as $associationName) {
$this->updateEntityField($sourceEntity, $targetEntity, $associationName);
}
}
/**
* Replace a single field/association on the target entity from the source entity.
*
* For PUT operations, we replace the entire resource, so we set the value
* from the source entity regardless of whether it's null or not.
*/
private function updateEntityField(object $sourceEntity, object $targetEntity, string $fieldName): void
{
$getter = 'get'.ucfirst($fieldName);
$setter = 'set'.ucfirst($fieldName);
if (method_exists($sourceEntity, $getter) && method_exists($targetEntity, $setter)) {
$value = $sourceEntity->$getter();
// For PUT, we replace the entire resource, so set the value even if it's null
$targetEntity->$setter($value);
}
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\Tests\ApiPlatform\EventListener;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use Mautic\ApiBundle\ApiPlatform\EventListener\MauticDenyAccessListener;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
final class MauticDenyAccessListenerTest extends TestCase
{
private MockObject&CorePermissions $corePermissionsMock;
private ApiResource $resourceMetadata;
private ResourceMetadataCollectionFactoryInterface&MockObject $resourceMetadataFactoryMock;
private RequestEvent $requestEvent;
private MauticDenyAccessListener $mauticDenyAccessListener;
protected function setUp(): void
{
$attributes = [
'_api_resource_class' => 'TestClass',
'_api_operation_name' => 'Test',
'item_operation_name' => 'Test',
];
$parameterBagMock = $this->createMock(ParameterBag::class);
$parameterBagMock
->expects($this->exactly(1))
->method('all')
->willReturn($attributes);
$formEntityMock = $this->createMock(FormEntity::class);
$formEntityMock
->expects($this->atMost(1))
->method('getCreatedBy')
->willReturn(0);
$parameterBagMock
->expects($this->exactly(1))
->method('get')
->with('data')
->willReturn($formEntityMock);
$requestMock = $this->createMock(Request::class);
$requestMock->attributes = $parameterBagMock;
$this->corePermissionsMock = $this->createMock(CorePermissions::class);
$this->resourceMetadataFactoryMock = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
$this->requestEvent = new RequestEvent(
$this->createMock(HttpKernelInterface::class),
$requestMock,
HttpKernelInterface::MAIN_REQUEST
);
$this->mauticDenyAccessListener = new MauticDenyAccessListener($this->corePermissionsMock, $this->resourceMetadataFactoryMock);
}
public function testOnSecurityEntityAccessAllowed(): void
{
$operations = [
new Get(
security: '"test_item:edit"',
name: 'Test'
),
];
$this->resourceMetadata = new ApiResource(operations: $operations);
$resourceMetadataCollection = new ResourceMetadataCollection('TestClass', [$this->resourceMetadata]);
$this->resourceMetadataFactoryMock
->expects($this->exactly(1))
->method('create')
->with('TestClass')
->willReturn($resourceMetadataCollection);
$this->corePermissionsMock
->expects($this->exactly(1))
->method('hasEntityAccess')
->with('test_item:editown', 'test_item:editother', 0)
->willReturn(true);
$this->mauticDenyAccessListener->onSecurity($this->requestEvent);
}
public function testOnSecurityIsGranted(): void
{
$operations = [
new Get(
security: '"test_item:write"',
name: 'Test'
),
];
$this->resourceMetadata = new ApiResource(operations: $operations);
$resourceMetadataCollection = new ResourceMetadataCollection('TestClass', [$this->resourceMetadata]);
$this->resourceMetadataFactoryMock
->expects($this->exactly(1))
->method('create')
->with('TestClass')
->willReturn($resourceMetadataCollection);
$this->corePermissionsMock
->expects($this->exactly(1))
->method('isGranted')
->with('test_item:write')
->willReturn(true);
$this->mauticDenyAccessListener->onSecurity($this->requestEvent);
}
public function testOnSecurityAccessDenied(): void
{
$operations = [
new Get(
security: '"test_item:write"',
name: 'Test'
),
];
$this->resourceMetadata = new ApiResource(operations: $operations);
$resourceMetadataCollection = new ResourceMetadataCollection('TestClass', [$this->resourceMetadata]);
$this->resourceMetadataFactoryMock
->expects($this->exactly(1))
->method('create')
->with('TestClass')
->willReturn($resourceMetadataCollection);
$this->corePermissionsMock
->expects($this->exactly(1))
->method('isGranted')
->with('test_item:write')
->willReturn(false);
$this->expectException(AccessDeniedException::class);
$this->mauticDenyAccessListener->onSecurity($this->requestEvent);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\Tests\ApiPlatform\EventListener;
use ApiPlatform\Symfony\EventListener\EventPriorities;
use Mautic\ApiBundle\ApiPlatform\EventListener\MauticWriteSubscriber;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\UserBundle\Entity\User;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class MauticWriteSubscriberTest extends TestCase
{
private MauticWriteSubscriber $mauticWriteSubscriber;
private ViewEvent $event;
private MockObject&FormEntity $formEntityMock;
private Request&MockObject $requestMock;
private UserHelper&MockObject $userHelperMock;
protected function setUp(): void
{
$this->userHelperMock = $this->createMock(UserHelper::class);
$this->mauticWriteSubscriber = new MauticWriteSubscriber($this->userHelperMock);
$this->requestMock = $this->createMock(Request::class);
$this->formEntityMock = $this->createMock(FormEntity::class);
$kernelMock = $this->createMock(HttpKernelInterface::class);
$this->event = new ViewEvent(
$kernelMock,
$this->requestMock,
HttpKernelInterface::MAIN_REQUEST,
$this->formEntityMock,
);
}
public function testGetSubscribedEvents(): void
{
$expected = [
'kernel.view'=> ['addData', EventPriorities::PRE_WRITE],
];
$this->assertEquals($expected, MauticWriteSubscriber::getSubscribedEvents());
}
public function testAddDataWithWrongMethod(): void
{
$this->requestMock
->expects($this->exactly(1))
->method('getMethod')
->willReturn('GET');
$this->formEntityMock
->expects($this->never())
->method('isNew');
$this->mauticWriteSubscriber->addData($this->event);
}
public function testAddData(): void
{
$this->requestMock
->expects($this->exactly(1))
->method('getMethod')
->willReturn('POST');
$this->formEntityMock
->expects($this->exactly(1))
->method('isNew')
->willReturn(false);
$userMock = $this->createMock(User::class);
$userMock
->expects($this->exactly(1))
->method('getName')
->willReturn('Pepa');
$this->userHelperMock
->expects($this->exactly(1))
->method('getUser')
->willReturn($userMock);
$this->formEntityMock
->expects($this->exactly(1))
->method('setModifiedBy')
->with($userMock);
$this->formEntityMock
->expects($this->exactly(1))
->method('setModifiedByUser')
->with('Pepa');
$this->formEntityMock
->expects($this->exactly(1))
->method('setDateModified')
->withAnyParameters();
$this->mauticWriteSubscriber->addData($this->event);
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace Mautic\ApiBundle\Tests\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Controller\CommonApiController;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\CampaignBundle\Tests\CampaignTestAbstract;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Model\AbstractCommonModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Entity\UserRepository;
use Mautic\UserBundle\Model\UserModel;
use Symfony\Bundle\FrameworkBundle\Routing\Router;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
class CommonApiControllerTest extends CampaignTestAbstract
{
public function testAddAliasIfNotPresentWithOneColumnWithoutAlias(): void
{
$result = $this->getResultFromProtectedMethod('addAliasIfNotPresent', ['dateAdded', 'f']);
$this->assertEquals('f.dateAdded', $result);
}
public function testAddAliasIfNotPresentWithOneColumnWithDifferentAlias(): void
{
$result = $this->getResultFromProtectedMethod('addAliasIfNotPresent', ['s.date_submitted', 'fs']);
$this->assertEquals('s.date_submitted', $result);
}
public function testAddAliasIfNotPresentWithOneColumnWithAlias(): void
{
$result = $this->getResultFromProtectedMethod('addAliasIfNotPresent', ['f.dateAdded', 'f']);
$this->assertEquals('f.dateAdded', $result);
}
public function testAddAliasIfNotPresentWithTwoColumnsWithAlias(): void
{
$result = $this->getResultFromProtectedMethod('addAliasIfNotPresent', ['f.dateAdded, f.dateModified', 'f']);
$this->assertEquals('f.dateAdded,f.dateModified', $result);
}
public function testAddAliasIfNotPresentWithTwoColumnsWithoutAlias(): void
{
$result = $this->getResultFromProtectedMethod('addAliasIfNotPresent', ['dateAdded, dateModified', 'f']);
$this->assertEquals('f.dateAdded,f.dateModified', $result);
}
public function testgetWhereFromRequestWithNoWhere(): void
{
$result = $this->getResultFromProtectedMethod('getWhereFromRequest', [new Request()]);
$this->assertEquals([], $result);
}
public function testgetWhereFromRequestWithSomeWhere(): void
{
$where = [
[
'col' => 'id',
'expr' => 'eq',
'val' => 5,
],
];
$request = new Request(['where' => $where]);
$result = $this->getResultFromProtectedMethod('getWhereFromRequest', [$request]);
$this->assertEquals($where, $result);
}
protected function getResultFromProtectedMethod($method, array $args)
{
$controller = new CommonApiController(
$this->createMock(CorePermissions::class),
$this->createMock(Translator::class),
$this->createMock(EntityResultHelper::class),
$this->createMock(Router::class),
$this->createMock(FormFactoryInterface::class),
$this->createMock(AppVersion::class),
$this->createMock(RequestStack::class),
$this->createMock(ManagerRegistry::class),
$this->createMock(ModelFactory::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(CoreParametersHelper::class)
);
$controllerReflection = new \ReflectionClass(CommonApiController::class);
$method = $controllerReflection->getMethod($method);
$method->setAccessible(true);
return $method->invokeArgs($controller, $args);
}
public function testGetBatchEntities(): void
{
$controller = new class($this->createMock(CorePermissions::class), $this->createMock(Translator::class), new EntityResultHelper(), $this->createMock(Router::class), $this->createMock(FormFactoryInterface::class), $this->createMock(AppVersion::class), $this->createMock(RequestStack::class), $this->createMock(ManagerRegistry::class), $this->createMock(ModelFactory::class), $this->createMock(EventDispatcherInterface::class), $this->createMock(CoreParametersHelper::class)) extends CommonApiController {
/**
* @param mixed[] $parameters
* @param mixed[] $errors
* @param AbstractCommonModel<User> $model
*
* @return mixed[]
*/
public function testGetBatchEntities(array $parameters, array $errors, AbstractCommonModel $model): array
{
return $this->getBatchEntities($parameters, $errors, false, 'id', $model);
}
};
$errors = [];
$parameters = [
[
'id' => 3,
'username' => 'API_0YjVvxlg',
'firstName' => 'APIAPI_0YjVvxlg',
'lastName' => 'TestAPI_0YjVvxlg',
'email' => 'API_0YjVvxlg@email.com',
'plainPassword' => [
'password' => 'topSecret007',
'confirm' => 'topSecret007',
],
'role' => 1,
],
1 => [
'id' => 4,
'username' => 'API_PlEiXJyp',
'firstName' => 'APIAPI_PlEiXJyp',
'lastName' => 'TestAPI_PlEiXJyp',
'email' => 'API_PlEiXJyp@email.com',
'plainPassword' => [
'password' => 'topSecret007',
'confirm' => 'topSecret007',
],
'role' => 1,
],
2 => [
'id' => 5,
'username' => 'API_AfhKVHTr',
'firstName' => 'APIAPI_AfhKVHTr',
'lastName' => 'TestAPI_AfhKVHTr',
'email' => 'API_AfhKVHTr@email.com',
'plainPassword' => [
'password' => 'topSecret007',
'confirm' => 'topSecret007',
],
'role' => 1,
],
];
$users = [];
foreach ([3, 5, 4] as $userId) {
$user = $this->createMock(User::class);
$user->expects($this->any())
->method('getId')
->willReturn($userId);
$users[] = $user;
}
$repository = $this->createMock(UserRepository::class);
$repository->expects($this->once())
->method('getTableAlias')
->willReturn('user');
$model = $this->createMock(UserModel::class);
$model->expects($this->once())
->method('getRepository')
->willReturn($repository);
$model->expects($this->once())
->method('getEntities')
->willReturn($users);
$entities = $controller->testGetBatchEntities($parameters, $errors, $model);
$this->assertSame(3, $entities[0]->getId());
$this->assertSame(4, $entities[1]->getId());
$this->assertSame(5, $entities[2]->getId());
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace Mautic\ApiBundle\Tests;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\LeadBundle\Entity\Lead;
use PHPUnit\Framework\TestCase;
class EntityResultHelperTest extends TestCase
{
public const NEW_TITLE = 'Callback Title';
public function testGetArrayEntities(): void
{
$resultHelper = new EntityResultHelper();
$lead2 = new Lead();
$lead2->setId(2);
$lead5 = new Lead();
$lead5->setId(5);
$results = [2 => $lead2, 5 => $lead5];
$arrayResult = $resultHelper->getArray($results);
$this->assertEquals($results, $arrayResult);
$arrayResult = $resultHelper->getArray($results, function ($entity): void {
$this->modifyEntityData($entity);
});
foreach ($arrayResult as $entity) {
$this->assertEquals($entity->getTitle(), self::NEW_TITLE);
}
}
public function testGetArrayPaginator(): void
{
$resultHelper = new EntityResultHelper();
$lead2 = new Lead();
$lead2->setId(2);
$lead5 = new Lead();
$lead5->setId(5);
$results = [$lead2, $lead5];
$iterator = new \ArrayIterator($results);
$paginator = $this->getMockBuilder(Paginator::class)
->disableOriginalConstructor()
->onlyMethods(['getIterator'])
->getMock();
$paginator->expects($this->any())
->method('getIterator')
->willReturn($iterator);
$arrayResult = $resultHelper->getArray($paginator);
$this->assertEquals($results, $arrayResult);
$arrayResult = $resultHelper->getArray($results, function ($entity): void {
$this->modifyEntityData($entity);
});
foreach ($arrayResult as $entity) {
$this->assertEquals($entity->getTitle(), self::NEW_TITLE);
}
}
public function testGetArrayAppendedData(): void
{
$resultHelper = new EntityResultHelper();
$lead2 = new Lead();
$lead2->setId(2);
$lead5 = new Lead();
$lead5->setId(5);
$lead7 = new Lead();
$lead7->setId(7);
$data = [[$lead2, 'title' => 'Title 2'], [$lead5, 'title' => 'Title 5'], [$lead7, 'title' => 'Title 7']];
$expectedResult = [$lead2, $lead5, $lead7];
$arrayResult = $resultHelper->getArray($data);
$this->assertEquals($expectedResult, $arrayResult);
foreach ($arrayResult as $entity) {
$this->assertEquals($entity->getTitle(), 'Title '.$entity->getId());
}
$arrayResult = $resultHelper->getArray($data, function ($entity): void {
$this->modifyEntityData($entity);
});
foreach ($arrayResult as $entity) {
$this->assertEquals($entity->getTitle(), self::NEW_TITLE);
}
}
private function modifyEntityData(Lead $entity): void
{
$entity->setTitle(self::NEW_TITLE);
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace Mautic\ApiBundle\Tests\EventListener;
use Mautic\ApiBundle\EventListener\ApiSubscriber;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Tests\CommonMocks;
use Mautic\CoreBundle\Translation\Translator;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\HttpFoundation\HeaderBag;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
class ApiSubscriberTest extends CommonMocks
{
/**
* @var CoreParametersHelper|MockObject
*/
private MockObject $coreParametersHelper;
/**
* @var Translator&MockObject
*/
private MockObject $translator;
/**
* @var Request&MockObject
*/
private MockObject $request;
/**
* @var RequestEvent&MockObject
*/
private MockObject $event;
private ApiSubscriber $subscriber;
protected function setUp(): void
{
parent::setUp();
$this->coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$this->translator = $this->createMock(Translator::class);
$this->request = $this->createMock(Request::class);
$this->request->headers = new HeaderBag();
$this->event = $this->createMock(RequestEvent::class);
$this->subscriber = new ApiSubscriber(
$this->coreParametersHelper,
$this->translator
);
}
public function testOnKernelRequestWhenNotMasterRequest(): void
{
$this->event->expects($this->once())
->method('isMainRequest')
->willReturn(false);
$this->coreParametersHelper->expects($this->never())
->method('get');
$this->subscriber->onKernelRequest($this->event);
}
public function testOnKernelRequestOnApiRequestWhenApiDisabled(): void
{
$this->event->expects($this->once())
->method('isMainRequest')
->willReturn(true);
$this->event->expects($this->once())
->method('getRequest')
->willReturn($this->request);
$this->request->expects($this->once())
->method('getRequestUri')
->willReturn('/api/endpoint');
$this->coreParametersHelper->expects($this->once())
->method('get')
->with('api_enabled')
->willReturn(false);
$this->event->expects($this->once())
->method('setResponse')
->with($this->isInstanceOf(JsonResponse::class))
->willReturnCallback(
function (JsonResponse $response): void {
$this->assertEquals(403, $response->getStatusCode());
}
);
$this->subscriber->onKernelRequest($this->event);
}
public function testOnKernelRequestOnApiRequestWhenApiEnabled(): void
{
$this->event->expects($this->once())
->method('isMainRequest')
->willReturn(true);
$this->event->expects($this->once())
->method('getRequest')
->willReturn($this->request);
$this->request->expects($this->once())
->method('getRequestUri')
->willReturn('/api/endpoint');
$matcher = $this->exactly(2);
$this->coreParametersHelper->expects($matcher)
->method('get')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame('api_enabled', $parameters[0]);
return true;
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame('api_enable_basic_auth', $parameters[0]);
return true;
}
});
$this->subscriber->onKernelRequest($this->event);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Mautic\ApiBundle\Tests\EventListener;
use Mautic\ApiBundle\EventListener\ConfigSubscriber;
use Mautic\ConfigBundle\Event\ConfigEvent;
use Mautic\CoreBundle\Tests\CommonMocks;
use Symfony\Component\HttpFoundation\ParameterBag;
class ConfigSubscriberTest extends CommonMocks
{
public function testWithUnsetApiBasicAuthSetting(): void
{
/**
* We need a config array where api_enable_basic_auth is not set
* (for example, in a hosted environment where customers are not allowed
* to enable basic auth on the API). Saving the config shouldn't throw
* any undefined notices/warnings in that case.
*/
$config = ['apiconfig' => []];
$subscriber = new ConfigSubscriber();
$configEvent = new ConfigEvent($config, new ParameterBag());
$subscriber->onConfigSave($configEvent);
$this->assertEquals($config, $configEvent->getConfig());
}
public function testWithIntegerApiBasicAuthSetting(): void
{
// Make sure the subscriber converts an integer value to boolean.
$config = [
'apiconfig' => [
'api_enable_basic_auth' => 1,
],
];
$fixedConfig = [
'api_enable_basic_auth' => true,
];
$subscriber = new ConfigSubscriber();
$configEvent = new ConfigEvent($config, new ParameterBag());
$subscriber->onConfigSave($configEvent);
$this->assertEquals($fixedConfig, $configEvent->getConfig('apiconfig'));
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\Tests\Form\Type;
use Mautic\ApiBundle\Entity\oAuth2\Client;
use Mautic\ApiBundle\Form\Type\ClientType;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ClientTypeTest extends TestCase
{
private ClientType $clientType;
/**
* @var MockObject&RequestStack
*/
private MockObject $requestStack;
/**
* @var MockObject&TranslatorInterface
*/
private MockObject $translator;
/**
* @var MockObject&ValidatorInterface
*/
private MockObject $validator;
/**
* @var MockObject&RouterInterface
*/
private MockObject $router;
/**
* @var MockObject&FormBuilderInterface
*/
private MockObject $builder;
/**
* @var MockObject&Request
*/
private MockObject $request;
private Client $client;
protected function setUp(): void
{
$this->requestStack = $this->createMock(RequestStack::class);
$this->translator = $this->createMock(TranslatorInterface::class);
$this->validator = $this->createMock(ValidatorInterface::class);
$this->router = $this->createMock(RouterInterface::class);
$this->builder = $this->createMock(FormBuilderInterface::class);
$this->request = $this->createMock(Request::class);
$this->client = new Client();
$this->requestStack->expects($this->once())
->method('getCurrentRequest')
->willReturn($this->request);
$this->request->expects($this->once())
->method('get')
->with('api_mode', null);
$this->clientType = new ClientType(
$this->requestStack,
$this->translator,
$this->validator,
$this->router
);
}
public function testThatBuildFormCallsEventSubscribers(): void
{
$options = [
'data' => $this->client,
];
$this->builder->expects($this->any())
->method('create')
->willReturnSelf();
$cleanSubscriber = new CleanFormSubscriber([]);
$formExitSubscriber = new FormExitSubscriber('api.client', $options);
$matcher = $this->exactly(2);
$this->builder->expects($matcher)
->method('addEventSubscriber')->willReturnCallback(function (...$parameters) use ($matcher, $cleanSubscriber, $formExitSubscriber) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertEquals($cleanSubscriber, $parameters[0]);
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertEquals($formExitSubscriber, $parameters[0]);
}
return $this->builder;
});
$this->clientType->buildForm($this->builder, $options);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\Tests\Functional\Controller;
use Mautic\ApiBundle\Entity\oAuth2\Client;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ClientControllerTest extends MauticMysqlTestCase
{
private const TOTAL_COUNT = 6;
#[\PHPUnit\Framework\Attributes\RunInSeparateProcess]
public function testIndexActionForPager(): void
{
$this->createApiClients();
// Test the first page without limits
$this->requestCredentialsPage();
$this->assertPaginationDetails(1);
// Test pagination with varying limits
$this->requestCredentialsPage(5);
$this->assertPaginationDetails(2);
}
private function createApiClients(): void
{
foreach (range(1, self::TOTAL_COUNT) as $i) {
$client = new Client();
$client->setName('client'.$i);
$client->setRedirectUris(['https://example.com/'.$i]);
$this->em->persist($client);
}
$this->em->flush();
$this->em->clear();
}
/**
* Make a request to the credentials page with pagination.
*/
private function requestCredentialsPage(?int $limit = null): void
{
$url = '/s/credentials?tmpl=list&name=client';
if ($limit) {
$url .= '&limit='.$limit;
}
$this->client->request(Request::METHOD_GET, $url);
}
/**
* Assert the pagination details on the response.
*
* @param int $pageCount The expected number of pages
*/
private function assertPaginationDetails(int $pageCount): void
{
$content = $this->client->getResponse()->getContent();
$this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
$translator = static::getContainer()->get('translator');
// Check for total item count in pagination
$this->assertStringContainsString(
$translator->trans('mautic.core.pagination.items', ['%count%' => self::TOTAL_COUNT]),
$content
);
// Check for total page count in pagination
$this->assertStringContainsString(
$translator->trans('mautic.core.pagination.pages', ['%count%' => $pageCount]),
$content
);
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\Tests\Functional;
use Mautic\CoreBundle\Test\IsolatedTestTrait;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* This test must run in a separate process because it sets the global constant
* MAUTIC_INSTALLER which breaks other tests.
*/
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
final class Oauth2Test extends MauticMysqlTestCase
{
use IsolatedTestTrait;
protected function setUp(): void
{
$this->useCleanupRollback = false;
parent::setUp();
}
#[\PHPUnit\Framework\Attributes\DataProvider('provideMethods')]
public function testAuthorize(string $method): void
{
// Disable the default logging in via username and password.
$this->clientServer = [];
$this->setUpSymfony($this->configParams);
$this->client->followRedirects(false);
$this->client->request(
$method,
'/oauth/v2/authorize'
);
$this->client->followRedirects(true);
$response = $this->client->getResponse();
Assert::assertSame(Response::HTTP_FOUND, $response->getStatusCode(), $response->getContent());
Assert::assertSame('https://localhost/oauth/v2/authorize_login', $response->headers->get('Location'));
}
public static function provideMethods(): \Generator
{
yield 'GET' => [Request::METHOD_GET];
yield 'POST' => [Request::METHOD_POST];
}
public function testAuthWithInvalidCredentials(): void
{
$this->client->enableReboot();
// Disable the default logging in via username and password.
$this->clientServer = [];
$this->setUpSymfony($this->configParams);
$this->client->request(
Request::METHOD_POST,
'/oauth/v2/token',
[
'grant_type' => 'client_credentials',
'client_id' => 'unicorn',
'client_secret' => 'secretUnicorn',
]
);
$response = $this->client->getResponse();
self::assertResponseStatusCodeSame(400, $response->getContent());
Assert::assertSame(
'{"errors":[{"message":"The client credentials are invalid","code":400,"type":"invalid_client"}]}',
$response->getContent()
);
}
public function testAuthWithInvalidAccessToken(): void
{
$this->client->enableReboot();
// Disable the default logging in via username and password.
$this->clientServer = [];
$this->setUpSymfony($this->configParams);
$this->client->request(
Request::METHOD_GET,
'/api/users',
[],
[],
[
'HTTP_Authorization' => 'Bearer unicorn_token',
],
);
$response = $this->client->getResponse();
self::assertResponseStatusCodeSame(401, $response->getContent());
Assert::assertSame('{"errors":[{"message":"The access token provided is invalid.","code":401,"type":"invalid_grant"}]}', $response->getContent());
}
public function testAuthWorkflow(): void
{
$this->client->disableReboot();
// Create OAuth2 credentials.
$crawler = $this->client->request(Request::METHOD_GET, 's/credentials/new');
$saveButton = $crawler->selectButton('Save');
$form = $saveButton->form();
$form['client[name]']->setValue('Auth Test');
$form['client[redirectUris]']->setValue('https://test.org');
$crawler = $this->client->submit($form);
self::assertResponseIsSuccessful();
$clientPublicKey = $crawler->filter('input#client_publicId')->attr('value');
$clientSecretKey = $crawler->filter('input#client_secret')->attr('value');
$this->logoutUser();
// Get the access token.
$this->client->request(
Request::METHOD_POST,
'/oauth/v2/token',
[
'grant_type' => 'client_credentials',
'client_id' => $clientPublicKey,
'client_secret' => $clientSecretKey,
],
);
self::assertResponseIsSuccessful();
$payload = json_decode($this->client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
$accessToken = $payload['access_token'];
Assert::assertNotEmpty($accessToken);
// Test that the access token works by fetching users via API.
$this->client->request(
Request::METHOD_GET,
'/api/users',
[],
[],
[
'HTTP_Authorization' => "Bearer {$accessToken}",
],
);
self::assertResponseIsSuccessful();
Assert::assertStringContainsString('"users":[', $this->client->getResponse()->getContent());
}
}

View File

@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace Mautic\ApiBundle\Tests\Functional\Serializer;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PageBundle\Entity\Page;
use Mautic\ProjectBundle\Entity\Project;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Response;
/**
* Functional test to verify that PUT operations update existing entities
* instead of creating new ones. This tests the PutProcessor fix end-to-end.
*/
final class PutOperationTest extends MauticMysqlTestCase
{
/**
* Test that API Platform GET endpoints work correctly.
* This helps isolate whether the issue is with PUT specifically or API Platform in general.
*/
public function testGetOperationWorks(): void
{
// Create a project
$project = new Project();
$project->setName('Test Project');
$project->setDescription('Test Description');
$this->em->persist($project);
$this->em->flush();
$projectId = $project->getId();
// Make a GET request to retrieve the project
$this->client->request('GET', '/api/v2/projects/'.$projectId);
$this->assertResponseIsSuccessful();
$responseData = json_decode($this->client->getResponse()->getContent(), true);
Assert::assertArrayHasKey('id', $responseData);
Assert::assertSame($projectId, $responseData['id']);
Assert::assertSame('Test Project', $responseData['name']);
Assert::assertSame('Test Description', $responseData['description']);
}
/**
* Test that PUT operations work globally for different entities (Page example).
* This verifies that our global PutProcessor fix works for all API Platform entities.
*/
public function testPutOperationWorksGloballyForPageEntity(): void
{
// Create a page
$page = new Page();
$page->setTitle('Original Page Title');
$page->setAlias('original-page-alias');
$page->setMetaDescription('Original Meta Description');
$this->em->persist($page);
$this->em->flush();
$originalId = $page->getId();
Assert::assertNotNull($originalId, 'Page should have an ID after persisting');
// Update the page via PUT request
$this->client->request(
'PUT',
'/api/v2/pages/'.$originalId,
[],
[],
[
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
json_encode([
'title' => 'Updated Page Title',
'alias' => 'updated-page-alias',
'metaDescription' => 'Updated Meta Description',
])
);
$this->assertResponseIsSuccessful();
$response = json_decode($this->client->getResponse()->getContent(), true);
// The key assertion: ID should remain the same (global fix working)
Assert::assertSame($originalId, $response['id'], 'PUT should update the existing page, not create a new one');
Assert::assertSame('Updated Page Title', $response['title']);
Assert::assertSame('updated-page-alias', $response['alias']);
Assert::assertSame('Updated Meta Description', $response['metaDescription']);
}
/**
* Test that PUT operation updates existing entity instead of creating a new one.
* This is the main regression test for the EntityContextBuilder fix.
*/
public function testPutOperationUpdatesExistingProject(): void
{
// Create initial project
$project = new Project();
$project->setName('Original Project');
$project->setDescription('Original Description');
$this->em->persist($project);
$this->em->flush();
$originalId = $project->getId();
Assert::assertNotNull($originalId, 'Project should have an ID after persisting');
// Update the project via PUT request
$this->client->request(
'PUT',
'/api/v2/projects/'.$originalId,
[],
[],
[
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
json_encode([
'name' => 'Updated Project',
'description' => 'Updated Description',
])
);
$this->assertResponseIsSuccessful();
$response = json_decode($this->client->getResponse()->getContent(), true);
// The key assertion: ID should remain the same (not creating a new entity)
Assert::assertSame($originalId, $response['id'], 'PUT should update the existing entity, not create a new one');
Assert::assertSame('Updated Project', $response['name']);
Assert::assertSame('Updated Description', $response['description']);
// Verify in database that only one project exists with the updated data
$this->em->clear();
$projects = $this->em->getRepository(Project::class)->findAll();
Assert::assertCount(1, $projects, 'Should only have one project in database after PUT');
Assert::assertSame($originalId, $projects[0]->getId());
Assert::assertSame('Updated Project', $projects[0]->getName());
Assert::assertSame('Updated Description', $projects[0]->getDescription());
}
/**
* Test that PUT request for non-existent entity returns 404.
*/
public function testPutOperationReturns404ForNonExistentProject(): void
{
$nonExistentId = 99999;
$this->client->request(
'PUT',
'/api/v2/projects/'.$nonExistentId,
[],
[],
[
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
json_encode([
'name' => 'Test Project',
'description' => 'Test Description',
])
);
Assert::assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode());
}
/**
* Test that POST operations still work correctly (create new entities).
*/
public function testPostOperationCreatesNewProject(): void
{
$this->client->request(
'POST',
'/api/v2/projects',
[],
[],
[
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
json_encode([
'name' => 'New Project',
'description' => 'New Description',
])
);
$this->assertResponseIsSuccessful();
$response = json_decode($this->client->getResponse()->getContent(), true);
Assert::assertIsInt($response['id']);
Assert::assertSame('New Project', $response['name']);
Assert::assertSame('New Description', $response['description']);
// Verify project was created in database
$this->em->clear();
$project = $this->em->getRepository(Project::class)->find($response['id']);
Assert::assertNotNull($project);
Assert::assertSame('New Project', $project->getName());
}
/**
* Test that PUT operation completely replaces the resource (proper HTTP PUT semantics).
* If a field is missing from the PUT request, it should be set to null in the existing entity.
*/
public function testPutOperationReplacesEntireResource(): void
{
// Create initial project with both name and description
$project = new Project();
$project->setName('Original Project');
$project->setDescription('Original Description');
$this->em->persist($project);
$this->em->flush();
$originalId = $project->getId();
Assert::assertNotNull($originalId, 'Project should have an ID after persisting');
// Update the project via PUT request with only name (no description)
// According to HTTP PUT semantics, this should clear the description
$this->client->request(
'PUT',
'/api/v2/projects/'.$originalId,
[],
[],
[
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
json_encode([
'name' => 'Updated Project Name Only',
// Note: description is intentionally omitted
])
);
Assert::assertSame(200, $this->client->getResponse()->getStatusCode());
$response = json_decode($this->client->getResponse()->getContent(), true);
// Verify the response shows the field was replaced (cleared)
Assert::assertSame($originalId, $response['id'], 'Should update existing project, not create new one');
Assert::assertSame('Updated Project Name Only', $response['name']);
// The API may not include null fields in the response, so check if key exists
if (array_key_exists('description', $response)) {
Assert::assertNull($response['description'], 'Description should be null since it was not provided in PUT request');
}
// Verify in database that the description was actually cleared
$this->em->clear();
$updatedProject = $this->em->getRepository(Project::class)->find($originalId);
Assert::assertNotNull($updatedProject);
Assert::assertSame('Updated Project Name Only', $updatedProject->getName());
Assert::assertNull($updatedProject->getDescription(), 'Description should be cleared in database');
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace Mautic\ApiBundle\Tests\Helper;
use Mautic\ApiBundle\Helper\BatchIdToEntityHelper;
use Mautic\LeadBundle\Entity\Lead;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
class BatchIdToEntityHelperTest extends TestCase
{
public function testIdsAreExtractedFromIdKeyArray(): void
{
$parameters = ['ids' => [1, 2, 3]];
$helper = new BatchIdToEntityHelper($parameters);
$this->assertEquals([1, 2, 3], $helper->getIds());
$parameters = ['ids' => [1 => 1, 2 => 2, 3 => 3]];
$helper = new BatchIdToEntityHelper($parameters);
$this->assertEquals([1, 2, 3], $helper->getIds());
}
public function testIdsAreExtractedFromIdKeyCSVString(): void
{
$parameters = ['ids' => '1,2,3'];
$helper = new BatchIdToEntityHelper($parameters);
$this->assertEquals([1, 2, 3], $helper->getIds());
}
public function testIdIsExtractedFromIdKeyWithNumericValue(): void
{
$parameters = ['ids' => '12'];
$helper = new BatchIdToEntityHelper($parameters);
$this->assertEquals([12], $helper->getIds());
}
public function testErrorSetForIdKeyThatsNotRecognized(): void
{
$parameters = ['ids' => 'foo'];
$helper = new BatchIdToEntityHelper($parameters);
$this->assertEquals([], $helper->getIds());
$this->assertTrue($helper->hasErrors());
$this->assertEquals(['mautic.api.call.id_missing'], $helper->getErrors());
}
public function testIdsAreExtractedFromSimpleArray(): void
{
$parameters = [1, 2, 3];
$helper = new BatchIdToEntityHelper($parameters);
$this->assertEquals([1, 2, 3], $helper->getIds());
$parameters = [1 => 1, 2 => 2, 3 => 3];
$helper = new BatchIdToEntityHelper($parameters);
$this->assertEquals([1, 2, 3], $helper->getIds());
}
public function testIdsAreExtractedFromAssociativeArray(): void
{
$parameters = [
['id' => 1, 'foo' => 'bar'],
['id' => 2, 'foo' => 'bar'],
['id' => 3, 'foo' => 'bar'],
];
$helper = new BatchIdToEntityHelper($parameters);
$this->assertEquals([1, 2, 3], $helper->getIds());
$parameters = [
1 => ['id' => 1, 'foo' => 'bar'],
2 => ['id' => 2, 'foo' => 'bar'],
3 => ['id' => 3, 'foo' => 'bar'],
];
$helper = new BatchIdToEntityHelper($parameters);
$this->assertEquals([1, 2, 3], $helper->getIds());
}
public function testErrorsSetForAssociativeArrayWhenIdKeyIsNotFound(): void
{
$parameters = [
['id' => 1, 'foo' => 'bar'],
['foo' => 'bar'],
['id' => 3, 'foo' => 'bar'],
];
$helper = new BatchIdToEntityHelper($parameters);
$this->assertEquals([1, 3], $helper->getIds());
$this->assertTrue($helper->hasErrors());
$this->assertEquals([1 => 'mautic.api.call.id_missing'], $helper->getErrors());
}
public function testOriginalKeyOrderingForIdKeyArray(): void
{
$entityMock1 = $this->createMock(Lead::class);
$entityMock1->expects($this->once())
->method('getId')
->willReturn(1);
$entityMock2 = $this->createMock(Lead::class);
$entityMock2->expects($this->once())
->method('getId')
->willReturn(2);
$entityMock4 = $this->createMock(Lead::class);
$entityMock4->expects($this->once())
->method('getId')
->willReturn(4);
// Simulating ID 3 as not found
$entities = [$entityMock4, $entityMock2, $entityMock1];
$parameters = ['ids' => [1, 2, 3, 4]];
$helper = new BatchIdToEntityHelper($parameters);
$orderedEntities = $helper->orderByOriginalKey($entities);
$this->assertEquals([0, 1, 2], array_keys($orderedEntities));
$parameters = ['ids' => [1 => 1, 2 => 2, 3 => 3, 4 => 4]];
$helper = new BatchIdToEntityHelper($parameters);
$orderedEntities = $helper->orderByOriginalKey($entities);
$this->assertEquals([1, 2, 4], array_keys($orderedEntities));
}
public function testOriginalKeyOrderingForIdKeyCSVString(): void
{
$entityMock1 = $this->createMock(Lead::class);
$entityMock1->expects($this->never())
->method('getId');
$entityMock2 = $this->createMock(Lead::class);
$entityMock2->expects($this->never())
->method('getId');
$entityMock4 = $this->createMock(Lead::class);
$entityMock4->expects($this->never())
->method('getId');
// Simulating ID 3 as not found
$entities = [$entityMock4, $entityMock2, $entityMock1];
$parameters = ['ids' => '1,2,3,4'];
$helper = new BatchIdToEntityHelper($parameters);
$orderedEntities = $helper->orderByOriginalKey($entities);
$this->assertEquals([0, 1, 2], array_keys($orderedEntities));
}
public function testOriginalKeyOrderingForSimpleArray(): void
{
$entityMock1 = $this->createMock(Lead::class);
$entityMock1->expects($this->once())
->method('getId')
->willReturn(1);
$entityMock2 = $this->createMock(Lead::class);
$entityMock2->expects($this->once())
->method('getId')
->willReturn(2);
$entityMock4 = $this->createMock(Lead::class);
$entityMock4->expects($this->once())
->method('getId')
->willReturn(4);
// Simulating ID 3 as not found
$entities = [$entityMock4, $entityMock2, $entityMock1];
$parameters = [1, 2, 3, 4];
$helper = new BatchIdToEntityHelper($parameters);
$orderedEntities = $helper->orderByOriginalKey($entities);
$this->assertEquals([0, 1, 2], array_keys($orderedEntities));
$parameters = [1 => 1, 2 => 2, 3 => 3, 4 => 4];
$helper = new BatchIdToEntityHelper($parameters);
$orderedEntities = $helper->orderByOriginalKey($entities);
$this->assertEquals([1, 2, 4], array_keys($orderedEntities));
}
public function testOriginalKeyOrderingForAssociativeArray(): void
{
$entityMock1 = $this->createMock(Lead::class);
$entityMock1->expects($this->once())
->method('getId')
->willReturn(1);
$entityMock2 = $this->createMock(Lead::class);
$entityMock2->expects($this->once())
->method('getId')
->willReturn(2);
$entityMock4 = $this->createMock(Lead::class);
$entityMock4->expects($this->once())
->method('getId')
->willReturn(4);
// Simulating ID 3 as not found
$entities = [$entityMock4, $entityMock2, $entityMock1];
$parameters = [
['id' => 1, 'foo' => 'bar'],
['id' => 2, 'foo' => 'bar'],
['id' => 3, 'foo' => 'bar'],
['id' => 4, 'foo' => 'bar'],
];
$helper = new BatchIdToEntityHelper($parameters);
$orderedEntities = $helper->orderByOriginalKey($entities);
$this->assertEquals([0, 1, 2], array_keys($orderedEntities));
$parameters = [
1 => ['id' => 1, 'foo' => 'bar'],
2 => ['id' => 2, 'foo' => 'bar'],
3 => ['id' => 3, 'foo' => 'bar'],
4 => ['id' => 4, 'foo' => 'bar'],
];
$helper = new BatchIdToEntityHelper($parameters);
$orderedEntities = $helper->orderByOriginalKey($entities);
$this->assertEquals([1, 2, 4], array_keys($orderedEntities));
}
public function testOriginalKeyOrderingForFullAssociativeArray(): void
{
$entityMock1 = $this->createMock(Lead::class);
$entityMock1
->method('getId')
->willReturn(1);
$entityMock2 = $this->createMock(Lead::class);
$entityMock2
->method('getId')
->willReturn(2);
$entityMock3 = $this->createMock(Lead::class);
$entityMock3
->method('getId')
->willReturn(3);
$entityMock4 = $this->createMock(Lead::class);
$entityMock4
->method('getId')
->willReturn(4);
$entities = [$entityMock4, $entityMock2, $entityMock1, $entityMock3];
$parameters = [
['id' => 1, 'foo' => 'bar'],
['id' => 2, 'foo' => 'bar'],
['id' => 3, 'foo' => 'bar'],
['id' => 4, 'foo' => 'bar'],
];
$helper = new BatchIdToEntityHelper($parameters);
$orderedEntities = $helper->orderByOriginalKey($entities);
$this->assertEquals([0, 1, 2, 3], array_keys($orderedEntities));
foreach ($parameters as $key => $contact) {
Assert::assertEquals($orderedEntities[$key]->getId(), $entities[$key]->getId());
}
$parameters = [
1 => ['id' => 1, 'foo' => 'bar'],
2 => ['id' => 2, 'foo' => 'bar'],
3 => ['id' => 3, 'foo' => 'bar'],
4 => ['id' => 4, 'foo' => 'bar'],
];
$helper = new BatchIdToEntityHelper($parameters);
$orderedEntities = $helper->orderByOriginalKey($entities);
$this->assertEquals([1, 2, 3, 4], array_keys($orderedEntities));
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Mautic\ApiBundle\Tests\Helper;
use Mautic\ApiBundle\Helper\RequestHelper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\HeaderBag;
use Symfony\Component\HttpFoundation\Request;
class RequestHelperTest extends TestCase
{
/**
* @var \PHPUnit\Framework\MockObject\MockObject|Request
*/
private \PHPUnit\Framework\MockObject\MockObject $request;
protected function setUp(): void
{
$this->request = $this->createMock(Request::class);
}
public function testIsBasicAuthWithValidBasicAuth(): void
{
$this->request->headers = new HeaderBag(['Authorization' => 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=']);
$this->assertTrue(RequestHelper::hasBasicAuth($this->request));
}
public function testIsBasicAuthWithInvalidBasicAuth(): void
{
$this->request->headers = new HeaderBag(['Authorization' => 'Invalid Basic Auth value']);
$this->assertFalse(RequestHelper::hasBasicAuth($this->request));
}
public function testIsBasicAuthWithMissingBasicAuth(): void
{
$this->request->headers = new HeaderBag([]);
$this->assertFalse(RequestHelper::hasBasicAuth($this->request));
}
public function testIsApiRequestWithOauthUrl(): void
{
$this->request->expects($this->once())
->method('getRequestUri')
->willReturn('/oauth/v2/token');
$this->assertTrue(RequestHelper::isApiRequest($this->request));
}
public function testIsApiRequestWithApiUrl(): void
{
$this->request->expects($this->once())
->method('getRequestUri')
->willReturn('/api/contacts');
$this->assertTrue(RequestHelper::isApiRequest($this->request));
}
public function testIsNotApiRequest(): void
{
$this->request->expects($this->once())
->method('getRequestUri')
->willReturn('/s/dashboard');
$this->assertFalse(RequestHelper::isApiRequest($this->request));
}
}

View File

@@ -0,0 +1,4 @@
mautic.api.client.error.notfound="Client not found with an ID of <strong>%id%</strong>."
mautic.api.client.notice.created="<a href='%url%' data-toggle='ajax'><strong>%name%</strong></a> has been created."
mautic.api.client.notice.revoked="API access has been revoked from the application, %name%"
mautic.api.call.permissionempty="At least one permission must be submitted."

View File

@@ -0,0 +1,61 @@
mautic.api.auth.error.accessdenied="API authorization denied."
mautic.api.auth.error.apidisabled="API access has been disabled. Please contact the system administrator"
mautic.api.auth.error.parameter_absent="The request has a missing parameter. If all parameters are present, a common reason for this error is typos in the Authorization header. Check for spelling errors, misplaced single/double quotes. etc. Remember that each OAuth Protocol parameter value must to be enclosed double quotes."
mautic.api.auth.error.timestamp_refused="The timestamp provided is invalid (either it doesn't have the right format, or it's out of the acceptable window)."
mautic.api.auth.error.nonce_used="The nonce received is not acceptable."
mautic.api.auth.error.signature_method_rejected="The signature method used is unsupported."
mautic.api.auth.error.signature_invalid="The signature provided does not match the one calculated by the service."
mautic.api.auth.error.consumer_key_unknown="The consumer key provided is unsupported."
mautic.api.auth.error.token_expired="The access token provided is valid, but has expired."
mautic.api.auth.error.token_rejected="The token provided does not have the right format."
mautic.api.auth.error.additional_authorization_required="The access token does not have the correct access scopes."
permission_denied="The access session handle (ASH) has expired or is invalid."
mautic.api.call.notfound="Object not found."
mautic.api.call.batch_exception="A max of %limit% entities are supported at a time."
mautic.api.call.id_missing="ID is missing from the payload."
mautic.api.client.form.auth_protocol="Authorization Protocol"
mautic.api.client.form.clientid="Client ID"
mautic.api.client.form.clientsecret="Client Secret"
mautic.api.client.form.confirmdelete="Delete the API client, %name%? Applications using this client will no longer have access to Mautic's API."
mautic.api.client.form.confirmrevoke="Revoke access for %name%?"
mautic.api.client.form.help.callback="Specify a callback URI that is allowed. Leave blank to restrict callbacks."
mautic.api.client.form.help.requesturis="Specify the URI(s) that are allowed API access. You can submit multiple URIs by separating them with commas."
mautic.api.client.form.name="Client Name"
mautic.api.client.form.revoke="Revoke Access"
mautic.api.client.header.edit="Credentials - Edit %name%"
mautic.api.client.header.index="API Credentials (Applications)"
mautic.api.client.header.new="Credentials - New Credential"
mautic.api.client.menu.index="API Credentials"
mautic.api.client.redirecturis="Redirect URI"
mautic.api.client.searchcommand.callback="callback"
mautic.api.client.searchcommand.redirecturi="redirecturi"
mautic.api.client.thead.publicid="Public Key"
mautic.api.client.thead.secret="Secret Key"
mautic.api.config.form.api.enabled="API enabled?"
mautic.api.config.form.api.basic_auth_enabled="Enable HTTP basic auth?"
mautic.api.config.form.api.basic_auth.tooltip="It is highly recommended to only use this with secure websites that have a SSL certificate (HTTPS)."
mautic.api.config.form.api.enabled.help="Enabling this option will add API Credentials as a new item under the admin menu."
mautic.api.config.form.api.oauth2_access_token_lifetime="Access token lifetime (in minutes)"
mautic.api.config.form.api.oauth2_access_token_lifetime.tooltip="If using OAuth2, set the lifetime of the access tokens used to authorize the request. Provides temporary, secure access to protected resources. Defaults to 60 minutes."
mautic.api.config.form.api.oauth2_refresh_token_lifetime="Refresh token lifetime (in days)"
mautic.api.config.form.api.oauth2_refresh_token_lifetime.tooltip="If using OAuth2, use it to request a new access token once expired. Enables continuous access without frequent user login. Defaults to 14 days."
mautic.api.config.oauth2="OAuth2"
mautic.api.oauth.accept="Accept"
mautic.api.oauth.auth.failed="OAuth authentication failed!"
mautic.api.oauth.clientnoname="An application would like to connect to your account."
mautic.api.oauth.clientwithname="The application <strong>%name%</strong> would like to connect to your account."
mautic.api.oauth.deny="Deny"
mautic.api.oauth.header="OAuth Authorization"
mautic.api.permissions.apiaccess="API Access"
mautic.api.permissions.clients="Clients (Applications) - User has access to"
mautic.api.client.contentblock.heading="Securely connect to other apps"
mautic.api.client.contentblock.subheading="Enable seamless communication between Mautic and your other essential tools using Mautic's API."
mautic.api.client.contentblock.copy="Credentials are the secure keys that allow external applications like your CRM, reporting dashboards, or custom-built tools to authenticate and safely interact with your Mautic data."
mautic.api.permissions.granted="Granted"
mautic.api.permissions.header="API Permissions"
mautic.config.tab.apiconfig="API Settings"
mautic.core.config.header.apiconfig.description="Configure API access and authentication to integrate with external applications."
mautic.core.error.badrequest="Bad request."
mautic.api.error.api.disabled="API disabled. You need to enable the API in the API settings of Mautic's Configuration."
mautic.api.error.basic.auth.disabled="Basic Auth disabled. You need to enable HTTP basic auth in the API settings of Mautic's Configuration."
mautic.api.error.basic.auth.invalid.credentials="Authorization denied, invalid credentials."

View File

@@ -0,0 +1,3 @@
mautic.api.client.callback.notblank="A callback URI is required."
mautic.api.client.redirecturis.notblank="A redirect URI is required."
mautic.api.client.redirecturl.invalid="%url% is an invalid URI."