Initial commit: CloudOps infrastructure platform
This commit is contained in:
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.col-client-id{
|
||||
width: 75px;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
@@ -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'),
|
||||
]);
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user