Initial commit: CloudOps infrastructure platform

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

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Api;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Request;
use Mautic\MarketplaceBundle\Exception\ApiException;
use Psr\Log\LoggerInterface;
class Connection
{
public function __construct(
private ClientInterface $httpClient,
private LoggerInterface $logger,
) {
}
/**
* @throws ApiException
*/
public function getPlugins(int $page, int $limit, string $query = ''): array
{
return $this->makeRequest("https://packagist.org/search.json?page={$page}&per_page={$limit}&type=mautic-plugin&q={$query}");
}
/**
* @throws ApiException
*/
public function getPackage(string $pluginName): array
{
return $this->makeRequest("https://packagist.org/packages/{$pluginName}.json");
}
public function makeRequest(string $url): array
{
$this->logger->debug('About to query the Packagist API: '.$url);
$request = new Request('GET', $url, $this->getHeaders());
try {
$response = $this->httpClient->send($request);
} catch (GuzzleException $e) {
throw new ApiException($e->getMessage(), $e->getCode());
}
$body = (string) $response->getBody();
if ($response->getStatusCode() >= 300) {
throw new ApiException($body, $response->getStatusCode());
}
$payload = json_decode($body, true);
$this->logger->debug('Successful Packagist API response', ['payload' => $payload]);
return $payload;
}
private function getHeaders(): array
{
return [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Accept-Encoding' => 'gzip, deflate, br',
'Connection' => 'keep-alive',
'User-Agent' => 'Mautic Marketplace',
];
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Collection;
use Mautic\MarketplaceBundle\DTO\Maintainer;
use Mautic\MarketplaceBundle\Exception\RecordNotFoundException;
class MaintainerCollection implements \Iterator, \Countable, \ArrayAccess
{
/**
* @var Maintainer[]
*/
private array $records;
private int $position = 0;
/**
* @param Maintainer[] $records
*/
public function __construct(array $records = [])
{
$this->records = array_values($records);
}
public static function fromArray(array $array): MaintainerCollection
{
return new self(
array_map(
fn (array $record) => Maintainer::fromArray($record),
$array
)
);
}
public function current(): Maintainer
{
return $this->records[$this->position];
}
public function next(): void
{
++$this->position;
}
public function key(): mixed
{
return $this->position;
}
public function valid(): bool
{
return isset($this->records[$this->position]);
}
public function rewind(): void
{
$this->position = 0;
}
public function count(): int
{
return count($this->records);
}
public function offsetSet($offset, $value): void
{
if (is_null($offset)) {
$this->records[] = $value;
} else {
$this->records[$offset] = $value;
}
}
public function offsetExists($offset): bool
{
return isset($this->records[$offset]);
}
public function offsetUnset($offset): void
{
unset($this->records[$offset]);
}
public function offsetGet($offset): Maintainer
{
if (isset($this->records[$offset])) {
return $this->records[$offset];
}
throw new RecordNotFoundException("Maintainer on offset {$offset} was not found");
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Collection;
use Mautic\MarketplaceBundle\DTO\PackageBase;
use Mautic\MarketplaceBundle\Exception\RecordNotFoundException;
class PackageCollection implements \Iterator, \Countable, \ArrayAccess
{
/**
* @var PackageBase[]
*/
private array $records;
private int $position = 0;
/**
* @param PackageBase[] $records
*/
public function __construct(array $records = [])
{
$this->records = array_values($records);
}
public static function fromArray(array $array): PackageCollection
{
return new self(
array_map(
fn (array $record) => PackageBase::fromArray($record),
$array
)
);
}
public function map(callable $callback): PackageCollection
{
return new self(array_map($callback, $this->records));
}
public function filter(callable $callback): PackageCollection
{
return new self(array_values(array_filter($this->records, $callback)));
}
public function current(): PackageBase
{
return $this->records[$this->position];
}
public function next(): void
{
++$this->position;
}
public function key(): mixed
{
return $this->position;
}
public function valid(): bool
{
return isset($this->records[$this->position]);
}
public function rewind(): void
{
$this->position = 0;
}
public function count(): int
{
return count($this->records);
}
public function offsetSet($offset, $value): void
{
if (is_null($offset)) {
$this->records[] = $value;
} else {
$this->records[$offset] = $value;
}
}
public function offsetExists($offset): bool
{
return isset($this->records[$offset]);
}
public function offsetUnset($offset): void
{
unset($this->records[$offset]);
}
public function offsetGet($offset): PackageBase
{
if (isset($this->records[$offset])) {
return $this->records[$offset];
}
throw new RecordNotFoundException("Package on offset {$offset} was not found");
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Collection;
use Mautic\MarketplaceBundle\DTO\Version;
use Mautic\MarketplaceBundle\Exception\RecordNotFoundException;
class VersionCollection implements \Iterator, \Countable, \ArrayAccess
{
/**
* @var Version[]
*/
private array $records;
private int $position = 0;
/**
* @param Version[] $records
*/
public function __construct(array $records = [])
{
$this->records = array_values($records);
}
public static function fromArray(array $array): VersionCollection
{
return new self(
array_map(
fn (array $record) => Version::fromArray($record),
$array
)
);
}
public function map(callable $callback): VersionCollection
{
return new self(array_map($callback, $this->records));
}
public function sortByLatest(): VersionCollection
{
$records = $this->records;
usort(
$records,
fn (Version $versionA, Version $versionB) => $versionB->time->getTimestamp() - $versionA->time->getTimestamp()
);
return new self($records);
}
public function filter(callable $callback): VersionCollection
{
return new self(array_values(array_filter($this->records, $callback)));
}
/**
* Finds the latest stable version. If no stable version is found, returns the version with latest timestamp.
*/
public function findLatestStableVersionPackage(): ?Version
{
return $this->sortByLatest()->filter(fn (Version $version) => $version->isStable())->first();
}
/**
* Finds the latest stable version. If no stable version is found, returns the version with latest timestamp.
*/
public function findLatestVersionPackage(): ?Version
{
return $this->sortByLatest()->first();
}
public function current(): Version
{
return $this->records[$this->position];
}
public function first(): ?Version
{
return $this->records[0] ?? null;
}
public function next(): void
{
++$this->position;
}
public function key(): mixed
{
return $this->position;
}
public function valid(): bool
{
return isset($this->records[$this->position]);
}
public function rewind(): void
{
$this->position = 0;
}
public function count(): int
{
return count($this->records);
}
public function offsetSet($offset, $value): void
{
if (is_null($offset)) {
$this->records[] = $value;
} else {
$this->records[$offset] = $value;
}
}
public function offsetExists($offset): bool
{
return isset($this->records[$offset]);
}
public function offsetUnset($offset): void
{
unset($this->records[$offset]);
}
public function offsetGet($offset): Version
{
if (isset($this->records[$offset])) {
return $this->records[$offset];
}
throw new RecordNotFoundException("Version on offset {$offset} was not found");
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Mautic\MarketplaceBundle\Command;
use Mautic\CoreBundle\Helper\ComposerHelper;
use Mautic\MarketplaceBundle\Exception\ApiException;
use Mautic\MarketplaceBundle\Model\PackageModel;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: InstallCommand::NAME,
description: 'Installs a plugin that is available at Packagist.org'
)]
class InstallCommand extends Command
{
public const NAME = 'mautic:marketplace:install';
public function __construct(
private ComposerHelper $composer,
private PackageModel $packageModel,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('package', InputArgument::REQUIRED, 'The Packagist package to install (e.g. mautic/example-plugin)');
$this->addOption('dry-run', null, null, 'Simulate the installation of the package. Doesn\'t actually install it.');
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$packageName = $input->getArgument('package');
$dryRun = true === $input->getOption('dry-run') ? true : false;
try {
$package = $this->packageModel->getPackageDetail($packageName);
} catch (ApiException $e) {
if (404 === $e->getCode()) {
throw new \InvalidArgumentException('Given package '.$packageName.' does not exist in Packagist. Please check the name for typos.');
} else {
throw new \Exception('Error while trying to get package details: '.$e->getMessage());
}
}
if (empty($package->packageBase->type) || 'mautic-plugin' !== $package->packageBase->type) {
throw new \Exception('Package type is not mautic-plugin. Cannot install this plugin.');
}
if ($dryRun) {
$output->writeLn('Note: dry-running this installation!');
}
$output->writeln('Installing '.$input->getArgument('package').', this might take a while...');
$result = $this->composer->install($input->getArgument('package'), $dryRun);
if (0 !== $result->exitCode) {
$output->writeln('<error>Error while installing this plugin.</error>');
if ($result->output) {
$output->writeln($result->output);
} else {
// If the output is empty then tell the user where to find more details.
$output->writeln('Check the logs for more details or run again with the -vvv parameter.');
}
return $result->exitCode;
}
$output->writeln('All done! '.$input->getArgument('package').' has successfully been installed.');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Mautic\MarketplaceBundle\Command;
use Mautic\MarketplaceBundle\DTO\PackageBase;
use Mautic\MarketplaceBundle\Service\PluginCollector;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Stopwatch\Stopwatch;
#[AsCommand(
name: ListCommand::NAME,
description: 'Lists plugins that are available at Packagist.org'
)]
class ListCommand extends Command
{
public const NAME = 'mautic:marketplace:list';
public function __construct(
private PluginCollector $pluginCollector,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('page', 'p', InputOption::VALUE_OPTIONAL, 'Page number', 1);
$this->addOption('limit', 'l', InputOption::VALUE_OPTIONAL, 'Packages per page', 15);
$this->addOption('filter', 'f', InputOption::VALUE_OPTIONAL, 'Filter the packages', '');
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$stopwatch = new Stopwatch();
$stopwatch->start('command');
$table = new Table($output);
$table->setHeaders(['name', 'downloads', 'favers']);
$plugins = $this->pluginCollector->collectPackages($input->getOption('page'), $input->getOption('limit'), $input->getOption('filter'));
/** @var PackageBase $plugin */
foreach ($plugins as $plugin) {
$color = 'white';
$delimiter = "\n ";
$description = $plugin->description ? $delimiter.wordwrap($plugin->description, 50, $delimiter) : '';
$table->addRow([
"<fg={$color}>{$plugin->name}{$description}</>",
"<fg={$color}>{$plugin->downloads}</>",
"<fg={$color}>{$plugin->favers}</>",
]);
}
$table->render();
$event = $stopwatch->stop('command');
$io->writeln("<fg=green>Total packages: {$this->pluginCollector->getTotal()}</>");
$io->writeln("<fg=green>Execution time: {$event->getDuration()} ms</>");
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Mautic\MarketplaceBundle\Command;
use Mautic\CoreBundle\Helper\ComposerHelper;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: RemoveCommand::NAME,
description: 'Removes a plugin that is currently installed'
)]
class RemoveCommand extends Command
{
public const NAME = 'mautic:marketplace:remove';
public function __construct(
private ComposerHelper $composer,
private LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('package', InputArgument::REQUIRED, 'The Packagist package of the plugin to remove (e.g. mautic/example-plugin)');
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Removing '.$input->getArgument('package').', this might take a while...');
$packageVendorAndName = $input->getArgument('package');
// Just checking the package type so that the user doesn't accidentially removes a core package
if (!in_array($packageVendorAndName, $this->composer->getMauticPluginPackages())) {
$output->writeln('This package cannot be removed, it must be of type mautic-plugin');
return Command::FAILURE;
}
$removeResult = $this->composer->remove($packageVendorAndName);
if (0 !== $removeResult->exitCode) {
$message = 'Error while removing plugin through Composer: '.$removeResult->output;
$this->logger->error($message);
$output->writeLn($message);
return Command::FAILURE;
}
$output->writeln($input->getArgument('package').' has successfully been removed.');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
use Mautic\MarketplaceBundle\Service\Config;
use Mautic\MarketplaceBundle\Service\RouteProvider;
return [
'routes' => [
'main' => [
RouteProvider::ROUTE_LIST => [
'path' => '/marketplace/{page}',
'controller' => 'Mautic\MarketplaceBundle\Controller\Package\ListController::listAction',
'method' => 'GET|POST',
'defaults' => ['page' => 1],
],
RouteProvider::ROUTE_DETAIL => [
'path' => '/marketplace/detail/{vendor}/{package}',
'controller' => 'Mautic\MarketplaceBundle\Controller\Package\DetailController::viewAction',
'method' => 'GET',
],
RouteProvider::ROUTE_INSTALL => [
'path' => '/marketplace/install/{vendor}/{package}',
'controller' => 'Mautic\MarketplaceBundle\Controller\Package\InstallController::viewAction',
'method' => 'GET|POST',
],
RouteProvider::ROUTE_REMOVE => [
'path' => '/marketplace/remove/{vendor}/{package}',
'controller' => 'Mautic\MarketplaceBundle\Controller\Package\RemoveController::viewAction',
'method' => 'GET|POST',
],
RouteProvider::ROUTE_CLEAR_CACHE => [
'path' => '/marketplace/clear/cache',
'controller' => 'Mautic\MarketplaceBundle\Controller\CacheController::clearAction',
'method' => 'GET',
],
],
],
'services' => [
'permissions' => [
'marketplace.permissions' => [
'class' => Mautic\MarketplaceBundle\Security\Permissions\MarketplacePermissions::class,
'arguments' => [
'mautic.helper.core_parameters',
'marketplace.service.config',
],
],
],
'api' => [
'marketplace.api.connection' => [
'class' => Mautic\MarketplaceBundle\Api\Connection::class,
'arguments' => [
'mautic.http.client',
'monolog.logger.mautic',
],
],
],
'other' => [
'marketplace.service.plugin_collector' => [
'class' => Mautic\MarketplaceBundle\Service\PluginCollector::class,
'arguments' => [
'marketplace.api.connection',
'marketplace.service.allowlist',
],
],
'marketplace.service.route_provider' => [
'class' => RouteProvider::class,
'arguments' => ['router'],
],
'marketplace.service.config' => [
'class' => Config::class,
'arguments' => [
'mautic.helper.core_parameters',
],
],
'marketplace.service.allowlist' => [
'class' => Mautic\MarketplaceBundle\Service\Allowlist::class,
'arguments' => [
'marketplace.service.config',
'mautic.cache.provider',
'mautic.http.client',
],
],
],
],
// NOTE: when adding new parameters here, please add them to the developer documentation as well:
'parameters' => [
Config::MARKETPLACE_ENABLED => true,
Config::MARKETPLACE_ALLOWLIST_URL => 'https://raw.githubusercontent.com/mautic/marketplace-allowlist/main/allowlist.json',
Config::MARKETPLACE_ALLOWLIST_CACHE_TTL_SECONDS => 3600,
],
];

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
];
$services->load('Mautic\\MarketplaceBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->alias('marketplace.model.package', Mautic\MarketplaceBundle\Model\PackageModel::class);
};

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\CacheHelper;
use Mautic\CoreBundle\Helper\ComposerHelper;
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\MarketplaceBundle\Security\Permissions\MarketplacePermissions;
use Mautic\MarketplaceBundle\Service\Config;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
class AjaxController extends CommonAjaxController
{
public function __construct(
private ComposerHelper $composer,
private CacheHelper $cacheHelper,
private LoggerInterface $logger,
private Config $config,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
UserHelper $userHelper,
CoreParametersHelper $coreParametersHelper,
EventDispatcherInterface $dispatcher,
Translator $translator,
FlashBag $flashBag,
RequestStack $requestStack,
CorePermissions $security,
) {
parent::__construct($doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
}
public function installPackageAction(Request $request): JsonResponse
{
if (!$this->config->marketplaceIsEnabled()) {
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.request.marketplace_disabled'),
], Response::HTTP_BAD_REQUEST);
}
if (!$this->security->isGranted(MarketplacePermissions::CAN_INSTALL_PACKAGES)
|| !$this->config->isComposerEnabled()) {
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.request.no_permissions'),
], Response::HTTP_FORBIDDEN);
}
$data = json_decode($request->getContent(), true);
if (empty($data['vendor']) || empty($data['package'])) {
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.request.details.missing'),
], 400);
}
$packageName = $data['vendor'].'/'.$data['package'];
if ($this->composer->isInstalled($packageName)) {
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.install.already.installed'),
], 400);
}
try {
$installResult = $this->composer->install($packageName);
if (Command::SUCCESS !== $installResult->exitCode) {
return $this->installError(new \Exception($installResult->output));
}
} catch (\Exception $e) {
return $this->installError($e);
}
// Note: do not do anything except returning a response after clearing the cache to prevent errors
$clearCacheResult = $this->clearCacheOrReturnError();
if (null !== $clearCacheResult) {
return $clearCacheResult;
}
return new JsonResponse(['success' => true]);
}
public function removePackageAction(Request $request): JsonResponse
{
if (!$this->config->marketplaceIsEnabled()) {
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.request.marketplace_disabled'),
], Response::HTTP_BAD_REQUEST);
}
if (!$this->security->isGranted(MarketplacePermissions::CAN_REMOVE_PACKAGES)
|| !$this->config->isComposerEnabled()) {
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.request.no_permissions'),
], Response::HTTP_FORBIDDEN);
}
$data = json_decode($request->getContent(), true);
if (empty($data['vendor']) || empty($data['package'])) {
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.request.details.missing'),
], 400);
}
$packageName = $data['vendor'].'/'.$data['package'];
if (!$this->composer->isInstalled($packageName)) {
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.remove.not.installed'),
], 400);
}
try {
$removeResult = $this->composer->remove($packageName);
if (0 !== $removeResult->exitCode) {
return $this->removeError(new \Exception($removeResult->output));
}
} catch (\Exception $e) {
return $this->removeError($e);
}
// Note: do not do anything except returning a response after clearing the cache to prevent errors
$clearCacheResult = $this->clearCacheOrReturnError();
if (null !== $clearCacheResult) {
return $clearCacheResult;
}
return new JsonResponse(['success' => true]);
}
private function clearCacheOrReturnError(): ?JsonResponse
{
try {
$exitCode = $this->cacheHelper->clearSymfonyCache();
if (0 !== $exitCode) {
$this->logger->error('Could not clear Mautic\'s cache. Please try again.');
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.cache.clear.failed'),
], 500);
}
} catch (\Exception $e) {
$this->logger->error('Could not clear Mautic\'s cache. Details: '.$e->getMessage());
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.cache.clear.failed'),
], 500);
}
return null;
}
private function installError(\Exception $e): JsonResponse
{
$this->logger->error('Installation of plugin through Composer has failed: '.$e->getMessage());
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.install.failed'),
], 500);
}
private function removeError(\Exception $e): JsonResponse
{
$this->logger->error('Error while removing package through Composer: '.$e->getMessage());
return $this->sendJsonResponse([
'error' => $this->translator->trans('marketplace.package.remove.failed'),
], 500);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CoreBundle\Controller\CommonController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\MarketplaceBundle\Security\Permissions\MarketplacePermissions;
use Mautic\MarketplaceBundle\Service\Allowlist;
use Mautic\MarketplaceBundle\Service\Config;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
class CacheController extends CommonController
{
public function __construct(
private Config $config,
private Allowlist $allowlist,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
UserHelper $userHelper,
CoreParametersHelper $coreParametersHelper,
EventDispatcherInterface $dispatcher,
Translator $translator,
FlashBag $flashBag,
RequestStack $requestStack,
CorePermissions $security,
) {
parent::__construct($doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
}
public function clearAction(): Response
{
if (!$this->config->marketplaceIsEnabled()) {
return $this->notFound();
}
if (!$this->security->isGranted(MarketplacePermissions::CAN_VIEW_PACKAGES)) {
return $this->accessDenied();
}
$this->allowlist->clearCache();
return $this->forward(
'Mautic\MarketplaceBundle\Controller\Package\ListController::listAction'
);
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Controller\Package;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CoreBundle\Controller\CommonController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\ComposerHelper;
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\MarketplaceBundle\Exception\RecordNotFoundException;
use Mautic\MarketplaceBundle\Model\PackageModel;
use Mautic\MarketplaceBundle\Security\Permissions\MarketplacePermissions;
use Mautic\MarketplaceBundle\Service\Config;
use Mautic\MarketplaceBundle\Service\RouteProvider;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
class DetailController extends CommonController
{
public function __construct(
private PackageModel $packageModel,
private RouteProvider $routeProvider,
private Config $config,
private ComposerHelper $composer,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
UserHelper $userHelper,
CoreParametersHelper $coreParametersHelper,
EventDispatcherInterface $dispatcher,
Translator $translator,
FlashBag $flashBag,
RequestStack $requestStack,
CorePermissions $security,
) {
parent::__construct($doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
}
public function viewAction(string $vendor, string $package): Response
{
if (!$this->config->marketplaceIsEnabled()) {
return $this->notFound();
}
if (!$this->security->isGranted(MarketplacePermissions::CAN_VIEW_PACKAGES)) {
return $this->accessDenied();
}
$isInstalled = $this->composer->isInstalled("{$vendor}/{$package}");
try {
$packageDetail = $this->packageModel->getPackageDetail("{$vendor}/{$package}");
} catch (RecordNotFoundException $e) {
return $this->notFound($e->getMessage());
}
$security = $this->security;
return $this->delegateView(
[
'returnUrl' => $this->routeProvider->buildListRoute(),
'viewParameters' => [
'packageDetail' => $packageDetail,
'isInstalled' => $isInstalled,
'isComposerEnabled' => $this->config->isComposerEnabled(),
'security' => $security,
],
'contentTemplate' => '@Marketplace/Package/detail.html.twig',
'passthroughVars' => [
'mauticContent' => 'package',
'activeLink' => '#mautic_marketplace',
'route' => $this->routeProvider->buildDetailRoute($vendor, $package),
],
]
);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Controller\Package;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CoreBundle\Controller\CommonController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\MarketplaceBundle\Model\PackageModel;
use Mautic\MarketplaceBundle\Security\Permissions\MarketplacePermissions;
use Mautic\MarketplaceBundle\Service\Config;
use Mautic\MarketplaceBundle\Service\RouteProvider;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
class InstallController extends CommonController
{
public function __construct(
private PackageModel $packageModel,
private RouteProvider $routeProvider,
private Config $config,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
UserHelper $userHelper,
CoreParametersHelper $coreParametersHelper,
EventDispatcherInterface $dispatcher,
Translator $translator,
FlashBag $flashBag,
RequestStack $requestStack,
CorePermissions $security,
) {
parent::__construct($doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
}
public function viewAction(string $vendor, string $package): Response
{
if (!$this->config->marketplaceIsEnabled()) {
return $this->notFound();
}
if (!$this->security->isGranted(MarketplacePermissions::CAN_INSTALL_PACKAGES)
|| !$this->config->isComposerEnabled()) {
return $this->accessDenied();
}
return $this->delegateView(
[
'returnUrl' => $this->routeProvider->buildListRoute(),
'viewParameters' => [
'packageDetail' => $this->packageModel->getPackageDetail("{$vendor}/{$package}"),
],
'contentTemplate' => '@Marketplace/Package/install.html.twig',
'passthroughVars' => [
'mauticContent' => 'package',
'activeLink' => '#mautic_marketplace',
'route' => $this->routeProvider->buildInstallRoute($vendor, $package),
],
]
);
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Controller\Package;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CoreBundle\Controller\CommonController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\MarketplaceBundle\Security\Permissions\MarketplacePermissions;
use Mautic\MarketplaceBundle\Service\Config;
use Mautic\MarketplaceBundle\Service\PluginCollector;
use Mautic\MarketplaceBundle\Service\RouteProvider;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
class ListController extends CommonController
{
public function __construct(
private PluginCollector $pluginCollector,
private RouteProvider $routeProvider,
ManagerRegistry $doctrine,
private Config $config,
ModelFactory $modelFactory,
UserHelper $userHelper,
CoreParametersHelper $coreParametersHelper,
EventDispatcherInterface $dispatcher,
Translator $translator,
FlashBag $flashBag,
RequestStack $requestStack,
CorePermissions $security,
) {
parent::__construct($doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
}
public function listAction(int $page = 1): Response
{
if (!$this->config->marketplaceIsEnabled()) {
return $this->notFound();
}
if (!$this->security->isGranted(MarketplacePermissions::CAN_VIEW_PACKAGES)) {
return $this->accessDenied();
}
$this->setListFilters();
$request = $this->getCurrentRequest();
$search = InputHelper::clean($request->get('search', ''));
$session = $request->getSession();
if (empty($page)) {
$page = $session->get('mautic.marketplace.package.page', 1);
}
// set limits
$limit = $session->get('mautic.marketplace.package.limit', $this->coreParametersHelper->get('default_pagelimit'));
$route = $this->routeProvider->buildListRoute($page);
return $this->delegateView(
[
'returnUrl' => $route,
'viewParameters' => [
'searchValue' => $search,
'items' => $this->pluginCollector->collectPackages($page, $limit, $search),
'count' => $this->pluginCollector->getTotal(),
'page' => $page,
'limit' => $limit,
'tmpl' => $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index',
'isComposerEnabled' => $this->config->isComposerEnabled(),
],
'contentTemplate' => '@Marketplace/Package/list.html.twig',
'passthroughVars' => [
'mauticContent' => 'package',
'route' => $route,
],
]
);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Controller\Package;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CoreBundle\Controller\CommonController;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\MarketplaceBundle\Model\PackageModel;
use Mautic\MarketplaceBundle\Security\Permissions\MarketplacePermissions;
use Mautic\MarketplaceBundle\Service\Config;
use Mautic\MarketplaceBundle\Service\RouteProvider;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
class RemoveController extends CommonController
{
public function __construct(
private PackageModel $packageModel,
private RouteProvider $routeProvider,
private Config $config,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
UserHelper $userHelper,
CoreParametersHelper $coreParametersHelper,
EventDispatcherInterface $dispatcher,
Translator $translator,
FlashBag $flashBag,
RequestStack $requestStack,
CorePermissions $security,
) {
parent::__construct($doctrine, $modelFactory, $userHelper, $coreParametersHelper, $dispatcher, $translator, $flashBag, $requestStack, $security);
}
public function viewAction(string $vendor, string $package): Response
{
if (!$this->config->marketplaceIsEnabled()) {
return $this->notFound();
}
if (!$this->security->isGranted(MarketplacePermissions::CAN_REMOVE_PACKAGES)) {
return $this->accessDenied();
}
return $this->delegateView(
[
'returnUrl' => $this->routeProvider->buildListRoute(),
'viewParameters' => [
'packageDetail' => $this->packageModel->getPackageDetail("{$vendor}/{$package}"),
],
'contentTemplate' => '@Marketplace/Package/remove.html.twig',
'passthroughVars' => [
'mauticContent' => 'package',
'activeLink' => '#mautic_marketplace',
'route' => $this->routeProvider->buildRemoveRoute($vendor, $package),
],
]
);
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\DTO;
use Mautic\MarketplaceBundle\Exception\RecordNotFoundException;
final class Allowlist
{
/**
* @param AllowlistEntry[] $entries
*/
public function __construct(
public array $entries,
) {
}
/**
* @param array<string,mixed> $array
*/
public static function fromArray(array $array): Allowlist
{
return new self(
array_map(fn (array $item) => AllowlistEntry::fromArray($item), $array['allowlist'] ?? []),
);
}
public function findPackageByName(string $packageName): AllowlistEntry
{
foreach ($this->entries as $entry) {
if ($entry->package === $packageName) {
return $entry;
}
}
throw new RecordNotFoundException("Package '$packageName' not found in allowlist.");
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\DTO;
final class AllowlistEntry
{
public function __construct(
/**
* Packagist package in the format vendor/package.
*/
public string $package,
/**
* Human readable name.
*/
public string $displayName,
/**
* Minimum Mautic version in semver format (e.g. 4.1.2).
*/
public ?string $minimumMauticVersion,
/**
* Maximum Mautic version in semver format (e.g. 4.1.2).
*/
public ?string $maximumMauticVersion,
) {
}
/**
* @param array<string,mixed> $array
*/
public static function fromArray(array $array): AllowlistEntry
{
return new self(
$array['package'],
$array['display_name'] ?? '',
$array['minimum_mautic_version'],
$array['maximum_mautic_version']
);
}
/**
* @return array<string,string>
*/
public function toArray(): array
{
return [
'package' => $this->package,
'display_name' => $this->displayName,
'minimum_mautic_version' => $this->minimumMauticVersion,
'maximum_mautic_version' => $this->maximumMauticVersion,
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\DTO;
final class ConsoleOutput
{
public function __construct(
/**
* Console exit code. 0 when everything went fine, or an error code.
*/
public int $exitCode,
public string $output,
) {
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\DTO;
final class GitHubInfo
{
public function __construct(
public int $stars,
public int $watchers,
public int $forks,
public int $openIssues,
) {
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\DTO;
final class Maintainer
{
public function __construct(
public string $name,
public string $avatar,
) {
}
public static function fromArray(array $array): Maintainer
{
return new self(
$array['name'],
$array['avatar_url']
);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\DTO;
final class PackageBase
{
public function __construct(
/**
* Original name in format "vendor/name".
*/
public string $name,
public string $url,
public string $repository,
public string $description,
public int $downloads,
public int $favers,
/**
* E.g. mautic-plugin.
*/
public ?string $type,
public ?string $displayName = null,
) {
}
public static function fromArray(array $array): self
{
return new self(
$array['name'],
$array['url'],
$array['repository'],
$array['description'],
(int) $array['downloads'],
(int) $array['favers'],
$array['type'] ?? null,
$array['display_name'] ?? null
);
}
/**
* Just an alias to getName(). Used in Mautic helpers.
*/
public function getId(): string
{
return $this->name;
}
/**
* Used in Mautic helpers.
*/
public function getName(): string
{
return $this->name;
}
public function getPackageName(): string
{
[, $packageName] = explode('/', $this->name);
return $packageName;
}
public function getHumanPackageName(): string
{
if ($this->displayName) {
return $this->displayName;
}
return utf8_ucwords(str_replace('-', ' ', $this->getPackageName()));
}
public function getVendorName(): string
{
[$vendor] = explode('/', $this->name);
return $vendor;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\DTO;
use Mautic\MarketplaceBundle\Collection\MaintainerCollection;
use Mautic\MarketplaceBundle\Collection\VersionCollection;
final class PackageDetail
{
public function __construct(
public PackageBase $packageBase,
public VersionCollection $versions,
public MaintainerCollection $maintainers,
public GitHubInfo $githubInfo,
public int $monthlyDownloads,
public int $dailyDownloads,
public \DateTimeInterface $time,
) {
}
public static function fromArray(array $array): self
{
return new self(
new PackageBase(
$array['name'],
"https://packagist.org/packages/{$array['name']}",
$array['repository'],
$array['description'],
(int) $array['downloads']['total'],
(int) $array['favers'],
$array['type'] ?? null,
$array['display_name'] ?? null
),
VersionCollection::fromArray($array['versions']),
MaintainerCollection::fromArray($array['maintainers']),
new GitHubInfo(
$array['github_stars'],
$array['github_watchers'],
$array['github_forks'],
$array['github_open_issues']
),
$array['downloads']['monthly'],
$array['downloads']['daily'],
new \DateTimeImmutable($array['time'])
);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\DTO;
final class Version
{
public function __construct(
public string $version,
public array $license,
public \DateTimeInterface $time,
public string $homepage,
public string $issues,
public string $wiki,
public array $require,
public array $keywords,
public ?string $type,
public ?string $directoryName,
public ?string $displayName,
) {
}
public static function fromArray(array $array): Version
{
return new self(
$array['version'],
$array['license'],
new \DateTimeImmutable($array['time']),
$array['homepage'],
$array['support']['issues'] ?? '',
$array['support']['wiki'] ?? '',
$array['require'] ?? [],
$array['keywords'] ?? [],
$array['type'] ?? null,
$array['extra']['install-directory-name'] ?? null,
$array['extra']['display-name'] ?? null
);
}
/**
* Consider a version stable if it is in SemVer fomrat "d.d.d".
*/
public function isStable(): bool
{
return 1 === preg_match('/^(\d+\.)?(\d+\.)?(\*|\d+)$/', $this->version);
}
/**
* Consider a version pre-release if it is in fomrat "d.d.d-s".
*/
public function isPreRelease(): bool
{
return 1 === preg_match('#^(\d+\.)?(\d+\.)?(\d+)(-[a-z0-9]+)?$#i', $this->version);
}
}

View File

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

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\Event\MenuEvent;
use Mautic\MarketplaceBundle\Security\Permissions\MarketplacePermissions;
use Mautic\MarketplaceBundle\Service\Config;
use Mautic\MarketplaceBundle\Service\RouteProvider;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class MenuSubscriber implements EventSubscriberInterface
{
public function __construct(
private Config $config,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::BUILD_MENU => ['onBuildMenu', 9999],
];
}
public function onBuildMenu(MenuEvent $event): void
{
if ('admin' !== $event->getType() || !$this->config->marketplaceIsEnabled()) {
return;
}
$event->addMenuItems(
[
'priority' => 81,
'items' => [
'marketplace.title' => [
'id' => 'marketplace',
'route' => RouteProvider::ROUTE_LIST,
'access' => MarketplacePermissions::CAN_VIEW_PACKAGES,
'parent' => 'mautic.core.integrations',
'iconClass' => 'ri-shopping-bag-2-line',
'priority' => 16,
],
],
]
);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Exception;
class ApiException extends \Exception
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Exception;
class RecordNotFoundException extends \Exception
{
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle;
use Mautic\PluginBundle\Bundle\PluginBundleBase;
class MarketplaceBundle extends PluginBundleBase
{
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Model;
use Mautic\MarketplaceBundle\Api\Connection;
use Mautic\MarketplaceBundle\DTO\PackageDetail;
use Mautic\MarketplaceBundle\Service\Allowlist;
class PackageModel
{
public function __construct(
private Connection $connection,
private Allowlist $allowlist,
) {
}
public function getPackageDetail(string $name): PackageDetail
{
$allowlist = $this->allowlist->getAllowList();
$allowedPackage = $allowlist->findPackageByName($name);
$payload = $this->connection->getPackage($name);
return PackageDetail::fromArray($payload['package'] + $allowedPackage->toArray());
}
}

View File

@@ -0,0 +1,78 @@
<div class="panel mt-sm">
<div class="panel-heading">
<div class="panel-title">{% trans %}marketplace.package.latest.stable.version{% endtrans %}</div>
</div>
<table class="table table-hover mb-0">
<tr>
<th>{% trans %}marketplace.package.version{% endtrans %}</th>
<td>
{% if not latestVersion %}
<div class="text-danger">
{% trans %}marketplace.latest.version.missing{% endtrans %}
</div>
{% else %}
<a href="{{ packageDetail.packageBase.repository|escape }}/releases/tag/{{ latestVersion.version|escape }}" id="latest-version" target="_blank" rel="noopener noreferrer">
<strong>{{ latestVersion.version }}</strong>
</a>
{% endif %}
</td>
</tr>
{% if latestVersion is not empty %}
<tr>
<th>{% trans %}marketplace.package.version.release.date{% endtrans %}</th>
<td title="{{ dateToText(latestVersion.time) }}">
{{ dateToDate(latestVersion.time) }}
</td>
</tr>
<tr>
<th>{% trans %}marketplace.package.license{% endtrans %}</th>
<td>{{ latestVersion.license|join(', ')|escape }}</td>
</tr>
{% if latestVersion.homepage %}
<tr>
<th>{% trans %}marketplace.package.homepage{% endtrans %}</th>
<td>{{ latestVersion.homepage|escape }}</td>
</tr>
{% endif %}
<tr>
<th>
{% trans %}marketplace.package.required.packages{% endtrans %}
({{ latestVersion.require|length }})
</th>
<td>{{ latestVersion.require|keys|join(', ')|escape }}</td>
</tr>
{% endif %}
</table>
</div>
<div class="panel">
<div class="panel-heading">
<div class="panel-title">{% trans %}marketplace.package.all.versions{% endtrans %}</div>
</div>
<table class="table table-hover mb-0">
<tr>
<th>{% trans %}marketplace.package.version{% endtrans %}</th>
<th>{% trans %}marketplace.package.version.release.date{% endtrans %}</th>
</tr>
{% for version in packageDetail.versions.sortByLatest() %}
<tr>
<td>
{% if version.isStable() or version.isPreRelease() %}
<a href="{{ packageDetail.packageBase.repository|escape }}/releases/tag/{{ version.version|escape }}" target="_blank" rel="noopener noreferrer" >
{% if version.isStable() %}
<b>{{ version.version|escape }}</b>
{% else %}
{{ version.version|escape }}
{% endif %}
</a>
{% else %}
<i>{{ version.version|escape }}</i>
{% endif %}
</td>
<td title="{{ dateToText(version.time) }}">
{{ dateToFullConcat(version.time) }}
</td>
</tr>
{% endfor %}
</table>
</div>

View File

@@ -0,0 +1,27 @@
<!-- GitHub -->
<table class="table table-hover mb-0">
<tr>
<th>{% trans %}marketplace.package.repository{% endtrans %}</th>
<td>
<a href="{{ packageDetail.packageBase.repository|escape }}" target="_blank" rel="noopener noreferrer" >
{{ packageDetail.packageBase.name|escape }}
</a>
</td>
</tr>
<tr>
<th>{% trans %}marketplace.package.github.stars{% endtrans %}</th>
<td>{{ packageDetail.githubInfo.stars|escape }}</td>
</tr>
<tr>
<th>{% trans %}marketplace.package.github.watchers{% endtrans %}</th>
<td>{{ packageDetail.githubInfo.watchers|escape }}</td>
</tr>
<tr>
<th>{% trans %}marketplace.package.github.forks{% endtrans %}</th>
<td>{{ packageDetail.githubInfo.forks|escape }}</td>
</tr>
<tr>
<th>{% trans %}marketplace.package.github.open.issues{% endtrans %}</th>
<td>{{ packageDetail.githubInfo.openIssues|escape }}</td>
</tr>
</table>

View File

@@ -0,0 +1,9 @@
<div class="panel panel-default mt-sm">
<div class="panel-heading">
<div class="panel-title">{% trans %}marketplace.package.cli.install{% endtrans %}</div>
</div>
<div class="panel-body">{{ 'marketplace.package.cli.install.descr'|trans({
'%vendor%' : packageDetail.packageBase.getVendorName(),
'%package%' : packageDetail.packageBase.getPackageName(),
})|purify }}</div>
</div>

View File

@@ -0,0 +1,29 @@
<!-- Packagist -->
<table class="table table-hover mb-0">
<tr>
<th>{% trans %}marketplace.package.repository{% endtrans %}</th>
<td>
<a href="{{ packageDetail.packageBase.url|escape }}" target="_blank" rel="noopener noreferrer" >
{{ packageDetail.packageBase.name|escape }}
</a>
</td>
</tr>
<tr>
<th>{% trans %}marketplace.package.total.downloads{% endtrans %}</th>
<td>{{ packageDetail.packageBase.downloads|escape }}</td>
</tr>
<tr>
<th>{% trans %}marketplace.package.monthly.downloads{% endtrans %}</th>
<td>{{ packageDetail.monthlyDownloads|escape }}</td>
</tr>
<tr>
<th>{% trans %}marketplace.package.daily.downloads{% endtrans %}</th>
<td>{{ packageDetail.dailyDownloads|escape }}</td>
</tr>
<tr>
<th>{% trans %}marketplace.package.create.date{% endtrans %}</th>
<td title="{{ dateToText(packageDetail.time) }}">
{{ dateToDate(packageDetail.time) }}
</td>
</tr>
</table>

View File

@@ -0,0 +1,243 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block headerTitle %}{% endblock %}
{% set latestVersion = packageDetail.versions.findLatestStableVersionPackage() %}
{% if not latestVersion %}
{% set latestVersion = packageDetail.versions.findLatestVersionPackage() %}
{% endif %}
{% block preHeader %}
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: [
{
label: 'mautic.core.close_back'|trans({'%target%': 'mautic.marketplace.marketplace'|trans}),
size: 'xs',
variant: 'tertiary',
icon: 'ri-arrow-left-line',
href: path(constant('Mautic\\MarketplaceBundle\\Service\\RouteProvider::ROUTE_LIST')),
attributes: {
'data-toggle': 'ajax',
'class': 'btn-back mb-lg'
}
}
]
} %}
{% endblock %}
{% block actions %}
{% set buttons = {} %}
{% if latestVersion and latestVersion.issues %}
{% set buttons = buttons|merge({0: {
'attr' : {
'href' : latestVersion.issues,
'target' : '_blank',
'rel' : 'noopener noreferrer',
'data-toggle' : '',
},
'btnText' : 'marketplace.package.issue.tracker'|trans,
'iconClass' : 'ri-question-mark',
'primary' : false,
}}) %}
{% endif %}
{% if latestVersion and latestVersion.wiki %}
{% set buttons = buttons|merge({0:{
'attr' : {
'href' : latestVersion.wiki,
'target' : '_blank',
'rel' : 'noopener noreferrer',
'data-toggle' : '',
},
'btnText' : 'marketplace.package.wiki'|trans,
'iconClass' : 'ri-book-line',
'primary' : false,
}}) %}
{% endif %}
{% if security.isGranted(constant('Mautic\\MarketplaceBundle\\Security\\Permissions\\MarketplacePermissions::CAN_INSTALL_PACKAGES')) and not isInstalled and isComposerEnabled %}
{% set installRoute = path(constant('Mautic\\MarketplaceBundle\\Service\\RouteProvider::ROUTE_INSTALL'),
{
'vendor' : packageDetail.packageBase.getVendorName(),
'package' : packageDetail.packageBase.getPackageName(),
}
) %}
{% set buttons = buttons|merge({0:{
'attr' : {
'data-toggle' : 'ajaxmodal',
'data-target' : '#InstallationInProgressModal',
'href' : installRoute,
},
'btnText' : 'marketplace.package.install'|trans,
'iconClass' : 'ri-download-line',
'primary' : true,
}}) %}
{% elseif security.isGranted(constant('Mautic\\MarketplaceBundle\\Security\\Permissions\\MarketplacePermissions::CAN_INSTALL_PACKAGES')) and isComposerEnabled %}
{% set removeRoute = path(constant('Mautic\\MarketplaceBundle\\Service\\RouteProvider::ROUTE_REMOVE'),
{'vendor' : packageDetail.packageBase.getVendorName(),
'package' : packageDetail.packageBase.getPackageName(),
}) %}
{% set buttons = buttons|merge({ 0: {
'attr' : {
'data-toggle' : 'ajaxmodal',
'data-target' :'#RemovalInProgressModal',
'href' : removeRoute,
},
'btnText' : 'marketplace.package.remove'|trans,
'iconClass' : 'ri-delete-bin-line',
'primary' : true,
}}) %}
{% endif %}
{{- include('@MauticCore/Helper/page_actions.html.twig', {
'customButtons' : buttons
}) -}}
{% endblock %}
{% block content %}
<div class="col-md-9">
<div class="marketplace-header {{ packageDetail.packageBase.type|default('')|purify }} bg-picture col-xs-12 jc-center pt-lg pb-lg">
<h1 class="fw-b fs-46">{{ packageDetail.packageBase.getHumanPackageName()|escape }}</h1>
{% if packageDetail.packageBase.description %}
<div class="text-muted mt-sm">{{ packageDetail.packageBase.description|purify }}</div>
{% endif %}
<hr>
<div class="d-flex gap-3 mt-sm">
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [
{
type: 'read-only',
color: 'warning',
label: packageDetail.githubInfo.stars|escape,
icon: 'ri-star-s-fill ri-lg',
attributes: {
'title': 'marketplace.package.github.stars'|trans,
'data-toggle': 'tooltip',
'size': 'md'
}
},
{
type: 'read-only',
color: 'warm-gray',
label: packageDetail.packageBase.downloads|escape,
icon: 'ri-download-line',
attributes: {
'title': 'marketplace.package.total.downloads'|trans,
'data-toggle': 'tooltip',
'size': 'md'
}
},
{
type: 'read-only',
color: 'warm-gray',
label: packageDetail.packageBase.type == 'theme'
? 'marketplace.package.type.theme'|trans
: 'marketplace.package.type.plugin'|trans,
icon: packageDetail.packageBase.type == 'theme'
? 'ri-paint-brush-line marketplace-icon'
: 'ri-plug-line',
attributes: {
'title': 'marketplace.package.type'|trans,
'data-toggle': 'tooltip',
'size': 'md'
}
},
{
type: 'read-only',
color: 'warm-gray',
label: latestVersion is not empty
? ('marketplace.package.last_updated'|trans ~ ' ' ~ dateToHumanized(latestVersion.time))
: '',
attributes: {
'size': 'md'
}
}
]
} %}
</div>
</div>
<hr>
{% include '@MauticCore/Helper/nav_tabs.html.twig' with {
'tabs': [
{
'title': 'mautic.core.overview',
'content': include('@bundles/MarketplaceBundle/Resources/views/Package/Details/details--tab_overview.html.twig')
},
{
'title': 'mautic.core.details',
'content': include('@bundles/MarketplaceBundle/Resources/views/Package/Details/details--tab_details.html.twig')
}
],
'style': 'line'
} %}
</div>
<div class="col-md-3 pb-lg">
<!-- Maintainers -->
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans %}marketplace.package.maintainers{% endtrans %}</h3>
</div>
{% for maintainer in packageDetail.maintainers %}
<div class="box-layout">
<div class="col-xs-3 va-m">
<div class="panel-body">
<span class="img-wrapper img-rounded">
<img class="img" src="{{ maintainer.avatar|escape }}">
</span>
</div>
</div>
<div class="col-xs-9 va-t">
<div class="panel-body">
<h4 class="fw-sb mb-xs ellipsis">
{{ maintainer.name|title|escape }}
</h4>
<a href="https://packagist.org/packages/{{ maintainer.name|escape }}" target="_blank" rel="noopener noreferrer">
{{ 'marketplace.other.packages'|trans({'%name%' : maintainer.name}) }}
</a>
</div>
</div>
</div>
{% endfor %}
</div>
<hr>
{% include '@MauticCore/Helper/nav_tabs.html.twig' with {
'tabs': [
{
'title': 'marketplace.package.github.info',
'content': include('@bundles/MarketplaceBundle/Resources/views/Package/Details/details--tab_github.html.twig')
},
{
'title': 'marketplace.package.packagist.info',
'content': include('@bundles/MarketplaceBundle/Resources/views/Package/Details/details--tab_packagist.html.twig')
}
],
'style': 'contained'
} %}
</div>
{{- include('@MauticCore/Helper/modal.html.twig', {
'id' : 'InstallationInProgressModal',
'header' : 'Installing ' ~ packageDetail.packageBase.getHumanPackageName()|escape,
'size' : 'md',
'footerButtons' : false,
}) -}}
{{- include('@MauticCore/Helper/modal.html.twig', {
'id' : 'RemovalInProgressModal',
'header' : 'Removing ' ~ packageDetail.packageBase.getHumanPackageName()|escape,
'size' : 'md',
'footerButtons' : false,
}) -}}
{% endblock %}

View File

@@ -0,0 +1,57 @@
<div class="text-center" id="marketplace-installation-in-progress">
<p>{{ 'marketplace.package.install.html.in.progress'|trans({'%packagename%' : packageDetail.packageBase.getHumanPackageName()})|purify }}</p>
<div class="spinner">
<i class="ri-loader-3-line ri-spin"></i>
</div>
</div>
<div style="display: none" class="text-center" id="marketplace-installation-failed">
<p>{{ 'marketplace.package.install.html.failed'|trans({'%packagename%' : packageDetail.packageBase.getHumanPackageName()})|purify }}</p>
<textarea class="form-control" readonly id="marketplace-installation-failed-details"></textarea>
</div>
<div style="display: none" class="text-center" id="marketplace-installation-success">
<p>{{ 'marketplace.package.install.html.success'|trans({'%packagename%' : packageDetail.packageBase.getHumanPackageName()})|purify }}</p>
<p><a class="btn btn-primary" href="{{ path('mautic_plugin_reload') }}">{% trans %}marketplace.package.install.html.success.continue{% endtrans %}</a></p>
</div>
<script>
const installPackageResetView = () => {
mQuery('#marketplace-installation-in-progress').show();
mQuery('#marketplace-installation-success').hide();
mQuery('#marketplace-installation-failed').hide();
}
installPackageResetView();
Mautic.Marketplace.installPackage(
'{{ packageDetail.packageBase.getVendorName()|escape }}',
'{{ packageDetail.packageBase.getPackageName()|escape }}',
(response) => {
if (response.success) {
mQuery('#marketplace-installation-in-progress').hide();
mQuery('#marketplace-installation-success').show();
} else if (response.redirect) {
window.location = response.redirect;
}
},
(request, textStatus, errorThrown) => {
let error;
try {
const res = JSON.parse(request.responseText);
if (res.error) {
error = res.error;
} else {
error = res.errors[0].message ?? 'Unknown error';
}
} catch (e) {
error = 'An unknown error occurred. Please check the logs for more details.';
console.error(request.responseText);
console.error(e);
}
mQuery('#marketplace-installation-in-progress').hide();
mQuery('#marketplace-installation-failed').show();
mQuery('#marketplace-installation-failed-details').text(error);
}
);
</script>

View File

@@ -0,0 +1,146 @@
{% set isIndex = tmpl == 'index' ? true : false %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent %}Package{% endblock %}
{% block headerTitle %}{{ 'marketplace.title'|trans|purify }}{% endblock %}
{% block actions %}
{{- include('@MauticCore/Helper/page_actions.html.twig', {
'customButtons' : {
0: {
'attr' : {
'class' : 'btn btn-primary btn-nospin',
'data-toggle' : 'ajax',
'href' : path(constant('Mautic\\MarketplaceBundle\\Service\\RouteProvider::ROUTE_CLEAR_CACHE')),
},
'iconClass' : 'ri-refresh-line',
'btnText' : 'marketplace.clear.cache',
'tooltip' : 'marketplace.clear.cache.tooltip',
},
},
}) -}}
{% endblock %}
{% block content %}
{% if isIndex %}
{% if isComposerEnabled %}
<div class="alert alert-info" role="alert">
{% trans %}marketplace.beta.warning{% endtrans %}
</div>
{% else %}
<div class="alert alert-warning" role="alert">
{% trans %}marketplace.composer.required{% endtrans %}
</div>
{% endif %}
<div id="page-list-wrapper" class="panel panel-default">
{{- include('@MauticCore/Helper/list_toolbar.html.twig', {
'searchValue' : searchValue,
'action' : currentRoute,
}) -}}
<div class="page-list">
{{ block('listResults') }}
</div>
</div>
{% else %}
{{ block('listResults') }}
{% endif %}
{% endblock %}
{% block listResults %}
{% if items|length %}
<div class="table-responsive">
<table class="table table-hover" id="marketplace-packages-table">
<thead>
<tr>
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'checkall' : 'true',
'target' : '#marketplace-packages-table',
'langVar' : 'marketplace.package',
'routeBase' : 'marketplace',
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'text' : 'mautic.core.name',
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'text' : 'marketplace.vendor',
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'text' : 'marketplace.downloads',
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'text' : 'marketplace.favers',
}
) -}}
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{{- include(
'@MauticCore/Helper/list_actions.html.twig',
{
'item' : item,
'customButtons' : {},
}
) -}}
</td>
<td class="package-name">
<div>
<a data-toggle="ajax" href="{{ path(
constant('Mautic\\MarketplaceBundle\\Service\\RouteProvider::ROUTE_DETAIL'),
{
'vendor' : item.getVendorName()|escape,
'package' : item.getPackageName()|escape,
}
) }}">
{{ item.getHumanPackageName()|escape }}
</a>
</div>
{{ include('@MauticCore/Helper/description--inline.html.twig', {
'description': item.description
}) }}
</td>
<td class="vendor-name">{{ item.getVendorName()|escape }}</td>
<td class="downloads">{{ item.downloads|escape }}</td>
<td class="favers">{{ item.favers|escape }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel-footer">
{{- include(
'@MauticCore/Helper/pagination.html.twig',
{
'totalItems' : count,
'page' : page,
'limit' : limit,
'baseUrl' : path(constant('Mautic\\MarketplaceBundle\\Service\\RouteProvider::ROUTE_LIST')),
'sessionVar' : 'marketplace.package',
'routeBase' : constant('Mautic\\MarketplaceBundle\\Service\\RouteProvider::ROUTE_LIST'),
}
) -}}
</div>
{% else %}
{{- include('@MauticCore/Helper/noresults.html.twig', {'message' : 'marketplace.noresults.tip'}) -}}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,58 @@
<div class="text-center" id="marketplace-removal-in-progress">
<p>{{ 'marketplace.package.remove.html.in.progress'|trans({'%packagename%' : packageDetail.packageBase.getHumanPackageName()})|purify }}</p>
<div class="spinner">
<i class="ri-loader-3-line ri-spin"></i>
</div>
</div>
<div style="display: none" class="text-center" id="marketplace-removal-failed">
<p>{{ 'marketplace.package.remove.html.failed'|trans({'%packagename%' : packageDetail.packageBase.getHumanPackageName()})|purify }}</p>
<textarea class="form-control" readonly id="marketplace-removal-failed-details"></textarea>
</div>
<div style="display: none" class="text-center" id="marketplace-removal-success">
<p>{{ 'marketplace.package.remove.html.success'|trans({'%packagename%' : packageDetail.packageBase.getHumanPackageName()})|purify }}</p>
<p><a class="btn btn-primary" href="{{ path(constant('Mautic\\MarketplaceBundle\\Service\\RouteProvider::ROUTE_LIST')) }}">
{% trans %}marketplace.package.remove.html.success.continue{% endtrans %}</a></p>
</div>
<script>
const removePackageResetView = () => {
mQuery('#marketplace-removal-in-progress').show();
mQuery('#marketplace-removal-success').hide();
mQuery('#marketplace-removal-failed').hide();
}
removePackageResetView();
Mautic.Marketplace.removePackage(
'{{ packageDetail.packageBase.getVendorName()|escape }}',
'{{ packageDetail.packageBase.getPackageName()|escape }}',
(response) => {
if (response.success) {
mQuery('#marketplace-removal-in-progress').hide();
mQuery('#marketplace-removal-success').show();
} else if (response.redirect) {
window.location = response.redirect;
}
},
(request, textStatus, errorThrown) => {
let error;
try {
const res = JSON.parse(request.responseText);
if (res.error) {
error = res.error;
} else {
error = res.errors[0].message ?? 'Unknown error';
}
} catch (e) {
error = 'An unknown error occurred. Please check the logs for more details.';
console.error(request.responseText);
console.error(e);
}
mQuery('#marketplace-removal-in-progress').hide();
mQuery('#marketplace-removal-failed').show();
mQuery('#marketplace-removal-failed-details').text(error);
}
);
</script>

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Security\Permissions;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
use Mautic\MarketplaceBundle\Service\Config;
use Symfony\Component\Form\FormBuilderInterface;
class MarketplacePermissions extends AbstractPermissions
{
public const BASE = 'marketplace';
public const PACKAGES = 'packages';
public const CAN_VIEW_PACKAGES = self::BASE.':'.self::PACKAGES.':view';
public const CAN_INSTALL_PACKAGES = self::BASE.':'.self::PACKAGES.':create';
public const CAN_REMOVE_PACKAGES = self::BASE.':'.self::PACKAGES.':remove';
public function __construct(
CoreParametersHelper $coreParametersHelper,
private Config $config,
) {
parent::__construct($coreParametersHelper->all());
}
public function definePermissions(): void
{
$this->addStandardPermissions(self::PACKAGES, false);
}
public function isEnabled(): bool
{
return $this->config->marketplaceIsEnabled();
}
public function getName(): string
{
return self::BASE;
}
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
{
$this->addStandardFormFields(self::BASE, self::PACKAGES, $builder, $data, false);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Service;
use GuzzleHttp\ClientInterface;
use Mautic\CacheBundle\Cache\CacheProvider;
use Mautic\MarketplaceBundle\DTO\Allowlist as DTOAllowlist;
use Mautic\MarketplaceBundle\Exception\ApiException;
/**
* Provides several helper functions to interact with Mautic's allowlist.
*/
class Allowlist
{
private const MARKETPLACE_ALLOWLIST_CACHE_KEY = 'marketplace_allowlist';
public function __construct(
private Config $config,
private CacheProvider $cache,
private ClientInterface $httpClient,
) {
}
public function getAllowList(): ?DTOAllowlist
{
$cache = $this->cache->getSimpleCache();
$cachedAllowlistString = $cache->get(self::MARKETPLACE_ALLOWLIST_CACHE_KEY);
if (!empty($cachedAllowlistString)) {
return $this->parseAllowlistJson($cachedAllowlistString);
}
if (!empty($this->config->getAllowListUrl())) {
$response = $this->httpClient->request('GET', $this->config->getAllowlistUrl());
$body = (string) $response->getBody();
if ($response->getStatusCode() >= 300) {
throw new ApiException($body, $response->getStatusCode());
}
// Cache the allowlist for the given amount of seconds (3600 by default).
$cache->set(
self::MARKETPLACE_ALLOWLIST_CACHE_KEY,
$body,
$this->config->getAllowlistCacheTtlSeconds()
);
return $this->parseAllowlistJson($body);
}
return null;
}
public function clearCache(): void
{
$this->cache->getSimpleCache()->delete(self::MARKETPLACE_ALLOWLIST_CACHE_KEY);
}
private function parseAllowlistJson(string $payload): DTOAllowlist
{
return DTOAllowlist::fromArray(json_decode($payload, true));
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Service;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
class Config
{
public const MARKETPLACE_ENABLED = 'marketplace_enabled';
public const MARKETPLACE_ALLOWLIST_URL = 'marketplace_allowlist_url';
public const MARKETPLACE_ALLOWLIST_CACHE_TTL_SECONDS = 'marketplace_allowlist_cache_ttl_seconds';
public function __construct(
private CoreParametersHelper $coreParametersHelper,
) {
}
public function marketplaceIsEnabled(): bool
{
return (bool) $this->coreParametersHelper->get(self::MARKETPLACE_ENABLED);
}
public function getAllowlistUrl(): string
{
return $this->coreParametersHelper->get(self::MARKETPLACE_ALLOWLIST_URL);
}
public function getAllowlistCacheTtlSeconds(): int
{
return (int) $this->coreParametersHelper->get(self::MARKETPLACE_ALLOWLIST_CACHE_TTL_SECONDS, 3600);
}
public function isComposerEnabled(): bool
{
return $this->coreParametersHelper->get('composer_updates', false);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Service;
use Mautic\CoreBundle\Release\ThisRelease;
use Mautic\MarketplaceBundle\Api\Connection;
use Mautic\MarketplaceBundle\Collection\PackageCollection;
use Mautic\MarketplaceBundle\DTO\AllowlistEntry;
class PluginCollector
{
/**
* @var AllowlistEntry[]
*/
private array $allowlistedPackages = [];
private int $total = 0;
public function __construct(
private Connection $connection,
private Allowlist $allowlist,
) {
}
public function collectPackages(int $page, int $limit, string $query = ''): PackageCollection
{
$allowlist = $this->allowlist->getAllowList();
if (!empty($allowlist)) {
$this->allowlistedPackages = $this->filterAllowlistedPackagesForCurrentMauticVersion($allowlist->entries);
$payload = $this->getAllowlistedPackages($page, $limit);
} else {
$payload = $this->connection->getPlugins($page, $limit, $query);
}
$this->total = (int) $payload['total'];
return PackageCollection::fromArray($payload['results']);
}
public function getTotal(): int
{
return $this->total;
}
/**
* @param AllowlistEntry[] $entries
*
* @return AllowlistEntry[]
*/
private function filterAllowlistedPackagesForCurrentMauticVersion(array $entries): array
{
$mauticVersion = ThisRelease::getMetadata()->getVersion();
return array_filter($entries, function (AllowlistEntry $entry) use ($mauticVersion): bool {
if (
!empty($entry->minimumMauticVersion)
&& !version_compare($mauticVersion, $entry->minimumMauticVersion, '>=')
) {
return false;
}
if (
!empty($entry->maximumMauticVersion)
&& !version_compare($mauticVersion, $entry->maximumMauticVersion, '<=')
) {
return false;
}
return true;
});
}
/**
* During the Marketplace beta period, we only want to show packages that are explicitly
* allowlisted. This function only gets allowlisted packages from Packagist. Their API doesn't
* support querying multiple packages at once, so we simply do a foreach loop.
*
* @return array<string,mixed>
*/
private function getAllowlistedPackages(int $page, int $limit): array
{
$total = count($this->allowlistedPackages);
$results = [];
if (0 === $total) {
return [
'total' => 0,
'results' => [],
];
}
/** @var array<int, AllowlistEntry[]> $chunks */
$chunks = array_chunk($this->allowlistedPackages, $limit);
// Array keys start at 0 but page numbers start at 1
$pageChunk = $page - 1;
foreach ($chunks[$pageChunk] as $entry) {
if (count($results) >= $limit) {
continue;
}
$payload = $this->connection->getPlugins(1, 1, $entry->package);
if (isset($payload['results'][0])) {
$results[] = $payload['results'][0] + $entry->toArray();
}
}
return [
'total' => $total,
'results' => $results,
];
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Service;
use Symfony\Component\Routing\RouterInterface;
class RouteProvider
{
public const ROUTE_LIST = 'mautic_marketplace_list';
public const ROUTE_DETAIL = 'mautic_marketplace_detail';
public const ROUTE_INSTALL = 'mautic_marketplace_install';
public const ROUTE_REMOVE = 'mautic_marketplace_remove';
public const ROUTE_CLEAR_CACHE = 'mautic_marketplace_clear_cache';
public function __construct(
private RouterInterface $router,
) {
}
public function buildListRoute(int $page = 1): string
{
return $this->router->generate(static::ROUTE_LIST, ['page' => $page]);
}
public function buildDetailRoute(string $vendor, string $package): string
{
return $this->router->generate(
static::ROUTE_DETAIL,
['vendor' => $vendor, 'package' => $package]
);
}
public function buildInstallRoute(string $vendor, string $package): string
{
return $this->router->generate(
static::ROUTE_DETAIL,
['vendor' => $vendor, 'package' => $package]
);
}
public function buildRemoveRoute(string $vendor, string $package): string
{
return $this->router->generate(
static::ROUTE_REMOVE,
['vendor' => $vendor, 'package' => $package]
);
}
public function buildClearCacheRoute(): string
{
return $this->router->generate(
static::ROUTE_CLEAR_CACHE
);
}
}

View File

@@ -0,0 +1,15 @@
{
"allowlist": [
{
"package": "koco\/mautic-recaptcha-bundle",
"display_name": "KocoCaptcha",
"minimum_mautic_version": "4.1.0",
"maximum_mautic_version": null
},
{
"package": "maatoo\/mautic-referrals-bundle",
"minimum_mautic_version": "4.1.0",
"maximum_mautic_version": null
}
]
}

View File

@@ -0,0 +1,512 @@
{
"package": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"time": "2018-01-10T09:30:31+00:00",
"maintainers": [
{
"name": "koco",
"avatar_url": "https:\/\/www.gravatar.com\/avatar\/73ad638ac9373ee1580bf6fb59e7538f?d=identicon"
}
],
"versions": {
"dev-master": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "dev-master",
"version_normalized": "dev-master",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "4855321ffa22e8c8435cd4a5ec45afec9857df84"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/4855321ffa22e8c8435cd4a5ec45afec9857df84",
"reference": "4855321ffa22e8c8435cd4a5ec45afec9857df84",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/3.0.1",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2020-07-22T10:07:15+00:00",
"default-branch": true,
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"dev-feature\/circleci-store_test_results": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "dev-feature\/circleci-store_test_results",
"version_normalized": "dev-feature\/circleci-store_test_results",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "0cdbec1b2e7427dff64d57e80789f79128105dea"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/0cdbec1b2e7427dff64d57e80789f79128105dea",
"reference": "0cdbec1b2e7427dff64d57e80789f79128105dea",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/feature\/circleci-store_test_results",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2018-10-28T18:31:03+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"dev-feature\/refactor": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "dev-feature\/refactor",
"version_normalized": "dev-feature\/refactor",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "702b25df5958f04cfdf1ccbfe34733082d379872"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/702b25df5958f04cfdf1ccbfe34733082d379872",
"reference": "702b25df5958f04cfdf1ccbfe34733082d379872",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/feature\/refactor",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2018-09-14T13:32:44+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"dev-feature\/only-delete-new-leads": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "dev-feature\/only-delete-new-leads",
"version_normalized": "dev-feature\/only-delete-new-leads",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "a37a878ba7b54226d1c162d7ca78258b408151fe"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/a37a878ba7b54226d1c162d7ca78258b408151fe",
"reference": "a37a878ba7b54226d1c162d7ca78258b408151fe",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/feature\/only-delete-new-leads",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2018-05-30T09:28:07+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"dev-feature\/delete-contact-on-invalid-form": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "dev-feature\/delete-contact-on-invalid-form",
"version_normalized": "dev-feature\/delete-contact-on-invalid-form",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "1d97afa904cc69da2a40066a7ffe67d2cea0aa0f"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/1d97afa904cc69da2a40066a7ffe67d2cea0aa0f",
"reference": "1d97afa904cc69da2a40066a7ffe67d2cea0aa0f",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/feature\/delete-contact-on-invalid-form",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2018-05-25T14:09:40+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"3.0.1": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "3.0.1",
"version_normalized": "3.0.1.0",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "4855321ffa22e8c8435cd4a5ec45afec9857df84"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/4855321ffa22e8c8435cd4a5ec45afec9857df84",
"reference": "4855321ffa22e8c8435cd4a5ec45afec9857df84",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/3.0.1",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2020-07-22T10:07:15+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"3.0.0": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "3.0.0",
"version_normalized": "3.0.0.0",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "971e5d29a6dfae8831b1ad478ebd80b2740e5f1d"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/971e5d29a6dfae8831b1ad478ebd80b2740e5f1d",
"reference": "971e5d29a6dfae8831b1ad478ebd80b2740e5f1d",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/master",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2020-04-20T14:45:33+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"1.1.3": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "1.1.3",
"version_normalized": "1.1.3.0",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "6736156e10ad53ddf64c300b618fe83b2790c944"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/6736156e10ad53ddf64c300b618fe83b2790c944",
"reference": "6736156e10ad53ddf64c300b618fe83b2790c944",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/master",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2018-10-02T15:45:41+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"1.1.2": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "1.1.2",
"version_normalized": "1.1.2.0",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "ac6855b6aebb22f6a80aff2589088449c6a1feca"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/ac6855b6aebb22f6a80aff2589088449c6a1feca",
"reference": "ac6855b6aebb22f6a80aff2589088449c6a1feca",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/master",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2018-09-14T13:35:51+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"1.1.1": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "1.1.1",
"version_normalized": "1.1.1.0",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "5d25d058fc246ff1ca9e196846443d113523af4a"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/5d25d058fc246ff1ca9e196846443d113523af4a",
"reference": "5d25d058fc246ff1ca9e196846443d113523af4a",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/master",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2018-09-14T12:03:24+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"1.1.0": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "1.1.0",
"version_normalized": "1.1.0.0",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "2866cd4868ea5f44823b9fc341b68f0c7ae7d8c1"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/2866cd4868ea5f44823b9fc341b68f0c7ae7d8c1",
"reference": "2866cd4868ea5f44823b9fc341b68f0c7ae7d8c1",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/master",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2018-05-30T09:37:18+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"1.0.1": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "1.0.1",
"version_normalized": "1.0.1.0",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "a29f1be60bf8c49a0b226712b700da29ab57a757"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/a29f1be60bf8c49a0b226712b700da29ab57a757",
"reference": "a29f1be60bf8c49a0b226712b700da29ab57a757",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/master",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2018-02-19T13:28:13+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
},
"1.0.0": {
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"keywords": [],
"homepage": "",
"version": "1.0.0",
"version_normalized": "1.0.0.0",
"license": [
"GNU General Public License v3.0"
],
"authors": [
{
"name": "Konstantin Scheumann",
"email": "info@konstantin.codes"
}
],
"source": {
"type": "git",
"url": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha.git",
"reference": "950854e08227187423e2ee99b028c9d92b4c1ba0"
},
"dist": {
"type": "zip",
"url": "https:\/\/api.github.com\/repos\/KonstantinCodes\/mautic-recaptcha\/zipball\/950854e08227187423e2ee99b028c9d92b4c1ba0",
"reference": "950854e08227187423e2ee99b028c9d92b4c1ba0",
"shasum": ""
},
"type": "mautic-plugin",
"support": {
"source": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/tree\/master",
"issues": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha\/issues"
},
"time": "2018-01-10T10:53:07+00:00",
"require": {
"mautic\/composer-plugin": "^1.0"
}
}
},
"type": "mautic-plugin",
"repository": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha",
"github_stars": 23,
"github_watchers": 9,
"github_forks": 17,
"github_open_issues": 9,
"language": "PHP",
"dependents": 0,
"suggesters": 0,
"downloads": {
"total": 2662,
"monthly": 430,
"daily": 6
},
"favers": 24
}
}

View File

@@ -0,0 +1,46 @@
{
"results": [
{
"name": "mautic\/mautic-saelos-bundle",
"description": "",
"url": "https:\/\/packagist.org\/packages\/mautic\/mautic-saelos-bundle",
"repository": "https:\/\/github.com\/mautic\/mautic-saelos-bundle",
"downloads": 10586,
"favers": 11
},
{
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"url": "https:\/\/packagist.org\/packages\/koco\/mautic-recaptcha-bundle",
"repository": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha",
"downloads": 2012,
"favers": 20
},
{
"name": "monogramm\/mautic-ldap-auth-bundle",
"description": "This plugin enables LDAP authentication for mautic.",
"url": "https:\/\/packagist.org\/packages\/monogramm\/mautic-ldap-auth-bundle",
"repository": "https:\/\/github.com\/Monogramm\/MauticLdapAuthBundle",
"downloads": 307,
"favers": 8
},
{
"name": "maatoo\/mautic-referrals-bundle",
"description": "This plugin enables referrals in mautic.",
"url": "https:\/\/packagist.org\/packages\/maatoo\/mautic-referrals-bundle",
"repository": "https:\/\/github.com\/maatoo-io\/MauticReferralsBundle",
"downloads": 527,
"favers": 5
},
{
"name": "thedmsgroup\/mautic-do-not-contact-extras-bundle",
"description": "Adds custom DNC list items to be added to standard Mautic DNC lists and creates phpne and sms channels",
"url": "https:\/\/packagist.org\/packages\/thedmsgroup\/mautic-do-not-contact-extras-bundle",
"repository": "https:\/\/github.com\/TheDMSGroup\/mautic-dnc-extras",
"downloads": 532,
"favers": 9
}
],
"total": 58,
"next": "https:\/\/packagist.org\/search.json?page=2\u0026type=mautic-plugin"
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Tests\Functional\Command;
use Mautic\CoreBundle\Helper\ComposerHelper;
use Mautic\CoreBundle\Test\AbstractMauticTestCase;
use Mautic\MarketplaceBundle\Command\InstallCommand;
use Mautic\MarketplaceBundle\DTO\ConsoleOutput;
use Mautic\MarketplaceBundle\DTO\PackageDetail;
use Mautic\MarketplaceBundle\Exception\ApiException;
use Mautic\MarketplaceBundle\Model\PackageModel;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\MockObject;
final class InstallCommandTest extends AbstractMauticTestCase
{
/**
* @var MockObject&ComposerHelper
*/
private MockObject $composerHelper;
/**
* @var MockObject&PackageModel
*/
private MockObject $packageModel;
private string $packageName;
public function setUp(): void
{
parent::setUp();
$this->composerHelper = $this->createMock(ComposerHelper::class);
$this->packageModel = $this->createMock(PackageModel::class);
$this->packageName = 'koco/mautic-recaptcha-bundle';
}
public function testInstallCommand(): void
{
$this->packageModel->method('getPackageDetail')
->with($this->packageName)
->willReturn($this->getPackageDetail());
$this->composerHelper->method('install')
->with($this->packageName)
->willReturn(new ConsoleOutput(0, 'OK'));
$command = new InstallCommand($this->composerHelper, $this->packageModel);
$result = $this->testSymfonyCommand(
'mautic:marketplace:install',
['package' => $this->packageName],
$command
);
Assert::assertSame(0, $result->getStatusCode());
}
public function testInstallCommandWithDryRun(): void
{
$this->packageModel->method('getPackageDetail')
->with($this->packageName)
->willReturn($this->getPackageDetail());
$this->composerHelper->method('install')
->with($this->packageName)
->willReturn(new ConsoleOutput(0, 'OK'));
$command = new InstallCommand($this->composerHelper, $this->packageModel);
$result = $this->testSymfonyCommand(
'mautic:marketplace:install',
['package' => $this->packageName, '--dry-run' => null],
$command
);
Assert::assertSame(0, $result->getStatusCode());
Assert::assertStringContainsString('dry-running this installation', $result->getDisplay());
}
public function testInstallCommandWithNonExistingPackage(): void
{
$packageName = 'mautic/non-existent-plugin';
$this->packageModel->method('getPackageDetail')
->with($packageName)
->willThrowException(new ApiException('Package not found', 404));
$command = new InstallCommand($this->composerHelper, $this->packageModel);
$this->expectException(\InvalidArgumentException::class);
$this->testSymfonyCommand(
'mautic:marketplace:install',
['package' => $packageName],
$command
);
}
public function testInstallCommandWithComposerNotAvailable(): void
{
$packageName = 'mautic/non-existent-plugin';
$this->packageModel->method('getPackageDetail')
->with($packageName)
->willThrowException(new ApiException('Internal Server Error', 500));
$command = new InstallCommand($this->composerHelper, $this->packageModel);
$this->expectException(\Exception::class);
$this->testSymfonyCommand(
'mautic:marketplace:install',
['package' => $packageName],
$command
);
}
public function testInstallCommandWithWrongPackageType(): void
{
$packageName = 'mautic/package-with-wrong-type';
$packageDetail = $this->getPackageDetail();
$packageDetail->packageBase->type = 'non-existent-type';
$this->packageModel->method('getPackageDetail')
->with($packageName)
->willReturn($packageDetail);
$command = new InstallCommand($this->composerHelper, $this->packageModel);
$this->expectException(\Exception::class);
$this->testSymfonyCommand(
'mautic:marketplace:install',
['package' => $packageName],
$command
);
}
public function testInstallCommandWithFailedComposerCommand(): void
{
$packageName = 'mautic/crash-package';
$this->composerHelper->method('install')
->with($packageName)
->willReturn(new ConsoleOutput(1, 'Something went wrong during the installation'));
$this->packageModel->method('getPackageDetail')
->with($packageName)
->willReturn($this->getPackageDetail());
$command = new InstallCommand($this->composerHelper, $this->packageModel);
$result = $this->testSymfonyCommand(
'mautic:marketplace:install',
['package' => $packageName],
$command
);
Assert::assertSame(1, $result->getStatusCode());
Assert::assertSame("Installing mautic/crash-package, this might take a while...\nError while installing this plugin.\nSomething went wrong during the installation\n", $result->getDisplay());
}
private function getPackageDetail(): PackageDetail
{
$payload = json_decode(file_get_contents(__DIR__.'/../../ApiResponse/detail.json'), true);
return PackageDetail::fromArray($payload['package']);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Tests\Functional\Command;
use Mautic\CoreBundle\Test\AbstractMauticTestCase;
use Mautic\MarketplaceBundle\Api\Connection;
use Mautic\MarketplaceBundle\Command\ListCommand;
use Mautic\MarketplaceBundle\DTO\Allowlist as DTOAllowlist;
use Mautic\MarketplaceBundle\Service\Allowlist;
use Mautic\MarketplaceBundle\Service\PluginCollector;
use PHPUnit\Framework\Assert;
final class ListCommandTest extends AbstractMauticTestCase
{
public function testCommand(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('getPlugins')
->willReturn(json_decode(file_get_contents(__DIR__.'/../../ApiResponse/list.json'), true));
$allowlist = $this->createMock(Allowlist::class);
$allowlist->method('getAllowlist')->willReturn(null);
$pluginCollector = new PluginCollector($connection, $allowlist);
$command = new ListCommand($pluginCollector);
$result = $this->testSymfonyCommand(
ListCommand::NAME,
[
'--page' => 1,
'--limit' => 5,
'--filter' => 'mautic',
],
$command
);
$expected = <<<EOF
+--------------------------------------------------------+-----------+--------+
| name | downloads | favers |
+--------------------------------------------------------+-----------+--------+
| mautic/mautic-saelos-bundle | 10586 | 11 |
| koco/mautic-recaptcha-bundle | 2012 | 20 |
| This plugin brings reCAPTCHA integration to | | |
| mautic. | | |
| monogramm/mautic-ldap-auth-bundle | 307 | 8 |
| This plugin enables LDAP authentication for | | |
| mautic. | | |
| maatoo/mautic-referrals-bundle | 527 | 5 |
| This plugin enables referrals in mautic. | | |
| thedmsgroup/mautic-do-not-contact-extras-bundle | 532 | 9 |
| Adds custom DNC list items to be added to standard | | |
| Mautic DNC lists and creates phpne and sms | | |
| channels | | |
+--------------------------------------------------------+-----------+--------+
Total packages: 58
Execution time:
EOF;
Assert::assertStringContainsString($expected, $result->getDisplay());
Assert::assertSame(0, $result->getStatusCode());
}
public function testCommmandWithAllowlist(): void
{
$page = 1;
$limit = 5;
$query = 'mautic';
$plugin1 = <<<EOF
{
"results": [
{
"name": "koco\/mautic-recaptcha-bundle",
"description": "This plugin brings reCAPTCHA integration to mautic.",
"url": "https:\/\/packagist.org\/packages\/koco\/mautic-recaptcha-bundle",
"repository": "https:\/\/github.com\/KonstantinCodes\/mautic-recaptcha",
"downloads": 2012,
"favers": 20
}
]
}
EOF;
$plugin2 = <<<EOF
{
"results": [
{
"name": "maatoo\/mautic-referrals-bundle",
"description": "This plugin enables referrals in mautic.",
"url": "https:\/\/packagist.org\/packages\/maatoo\/mautic-referrals-bundle",
"repository": "https:\/\/github.com\/maatoo-io\/MauticReferralsBundle",
"downloads": 527,
"favers": 5
}
]
}
EOF;
$connection = $this->createMock(Connection::class);
$matcher = $this->exactly(2);
$connection->expects($matcher)->method('getPlugins')->willReturnCallback(function (...$parameters) use ($matcher, $plugin1, $plugin2) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame(1, $parameters[0]);
$this->assertSame(1, $parameters[1]);
$this->assertSame('koco/mautic-recaptcha-bundle', $parameters[2]);
return json_decode($plugin1, true);
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame(1, $parameters[0]);
$this->assertSame(1, $parameters[1]);
$this->assertSame('maatoo/mautic-referrals-bundle', $parameters[2]);
return json_decode($plugin2, true);
}
});
$allowlistPayload = DTOAllowlist::fromArray(json_decode(file_get_contents(__DIR__.'/../../ApiResponse/allowlist.json'), true));
$allowlist = $this->createMock(Allowlist::class);
$allowlist->method('getAllowList')->willReturn($allowlistPayload);
$pluginCollector = new PluginCollector($connection, $allowlist);
$command = new ListCommand($pluginCollector);
$result = $this->testSymfonyCommand(
ListCommand::NAME,
[
'--page' => $page,
'--limit' => $limit,
'--filter' => $query,
],
$command
);
$expected = <<<EOF
+-------------------------------------------------+-----------+--------+
| name | downloads | favers |
+-------------------------------------------------+-----------+--------+
| koco/mautic-recaptcha-bundle | 2012 | 20 |
| This plugin brings reCAPTCHA integration to | | |
| mautic. | | |
| maatoo/mautic-referrals-bundle | 527 | 5 |
| This plugin enables referrals in mautic. | | |
+-------------------------------------------------+-----------+--------+
Total packages: 2
Execution time:
EOF;
Assert::assertStringContainsString($expected, $result->getDisplay());
Assert::assertSame(0, $result->getStatusCode());
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Tests\Functional\Command;
use Mautic\CoreBundle\Helper\ComposerHelper;
use Mautic\CoreBundle\Test\AbstractMauticTestCase;
use Mautic\MarketplaceBundle\Command\RemoveCommand;
use Mautic\MarketplaceBundle\DTO\ConsoleOutput;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
final class RemoveCommandTest extends AbstractMauticTestCase
{
/**
* @var MockObject&LoggerInterface
*/
private MockObject $logger;
private string $packageName;
public function setUp(): void
{
parent::setUp();
$this->logger = $this->createMock(LoggerInterface::class);
$this->packageName = 'koco/mautic-recaptcha-bundle';
}
public function testRemoveCommand(): void
{
$composer = $this->createMock(ComposerHelper::class);
$composer->method('remove')
->with($this->packageName)
->willReturn(new ConsoleOutput(0, 'OK'));
$composer->method('getMauticPluginPackages')
->willReturn(['koco/mautic-recaptcha-bundle']);
$command = new RemoveCommand($composer, $this->logger);
$result = $this->testSymfonyCommand(
'mautic:marketplace:remove',
['package' => $this->packageName],
$command
);
Assert::assertSame(0, $result->getStatusCode());
}
public function testRemoveCommandWithInvalidPackageType(): void
{
$composer = $this->createMock(ComposerHelper::class);
$composer->method('remove')
->with($this->packageName)
->willReturn(new ConsoleOutput(0, 'OK'));
$composer->method('getMauticPluginPackages')
->willReturn([]);
$command = new RemoveCommand($composer, $this->logger);
$result = $this->testSymfonyCommand(
'mautic:marketplace:remove',
['package' => $this->packageName],
$command
);
Assert::assertSame(1, $result->getStatusCode());
}
public function testRemoveCommandWithComposerError(): void
{
$composer = $this->createMock(ComposerHelper::class);
$composer->method('remove')
->with($this->packageName)
->willReturn(new ConsoleOutput(1, 'Error while removing package'));
$composer->method('getMauticPluginPackages')
->willReturn([]);
$command = new RemoveCommand($composer, $this->logger);
$result = $this->testSymfonyCommand(
'mautic:marketplace:remove',
['package' => $this->packageName],
$command
);
Assert::assertSame(1, $result->getStatusCode());
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Tests\Functional\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\CacheHelper;
use Mautic\CoreBundle\Helper\ComposerHelper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Test\AbstractMauticTestCase;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\MarketplaceBundle\Controller\AjaxController;
use Mautic\MarketplaceBundle\DTO\ConsoleOutput;
use Mautic\MarketplaceBundle\Security\Permissions\MarketplacePermissions;
use Mautic\MarketplaceBundle\Service\Config;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
final class AjaxControllerTest extends AbstractMauticTestCase
{
/**
* @var MockObject|CorePermissions
*/
private MockObject $security;
/**
* @var MockObject|Config
*/
private MockObject $marketplaceConfig;
/**
* @var MockObject|RequestStack
*/
private MockObject $requestStack;
public function testInstallPackageAction(): void
{
$request = new Request([], [], [], [], [], [], '{"vendor":"mautic","package":"test-plugin-bundle"}');
$controller = $this->generateController(false);
$this->marketplaceConfig->method('marketplaceIsEnabled')->willReturn(true);
$this->marketplaceConfig->method('isComposerEnabled')->willReturn(true);
$this->security->expects($this->any())
->method('isGranted')
->with(MarketplacePermissions::CAN_INSTALL_PACKAGES)
->willReturn(true);
$response = $controller->installPackageAction($request);
Assert::assertSame('{"success":true}', $response->getContent());
Assert::assertSame(200, $response->getStatusCode());
}
public function testRemovePackageAction(): void
{
$request = new Request([], [], [], [], [], [], '{"vendor":"mautic","package":"test-plugin-bundle"}');
$controller = $this->generateController(true);
$this->marketplaceConfig->method('marketplaceIsEnabled')->willReturn(true);
$this->marketplaceConfig->method('isComposerEnabled')->willReturn(true);
$this->security->expects($this->any())
->method('isGranted')
->with(MarketplacePermissions::CAN_REMOVE_PACKAGES)
->willReturn(true);
$response = $controller->removePackageAction($request);
Assert::assertSame('{"success":true}', $response->getContent());
Assert::assertSame(200, $response->getStatusCode());
}
private function generateController(bool $isPackageInstalled): AjaxController
{
$composer = $this->createMock(ComposerHelper::class);
$composer->method('install')->willReturn(new ConsoleOutput(0, 'OK'));
$composer->method('remove')->willReturn(new ConsoleOutput(0, 'OK'));
$composer->method('isInstalled')->willReturn($isPackageInstalled);
$cacheHelper = $this->createMock(CacheHelper::class);
$cacheHelper->method('clearSymfonyCache')->willReturn(0);
$logger = $this->createMock(LoggerInterface::class);
$doctrine = $this->createMock(ManagerRegistry::class);
$modelFactory = $this->createMock(ModelFactory::class);
$userHelper = $this->createMock(UserHelper::class);
$coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$translator = $this->createMock(Translator::class);
$flashBag = $this->createMock(FlashBag::class);
$this->requestStack = $this->createMock(RequestStack::class);
$this->security = $this->createMock(CorePermissions::class);
$this->marketplaceConfig = $this->createMock(Config::class);
$controller = new AjaxController(
$composer,
$cacheHelper,
$logger,
$this->marketplaceConfig,
$doctrine,
$modelFactory,
$userHelper,
$coreParametersHelper,
$dispatcher,
$translator,
$flashBag,
$this->requestStack,
$this->security
);
$controller->setContainer(static::getContainer());
return $controller;
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Tests\Functional\Controller;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Response;
use Mautic\CoreBundle\Test\Guzzle\ClientMockTrait;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\MarketplaceBundle\Service\Allowlist;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
final class DetailControllerTest extends MauticMysqlTestCase
{
use ClientMockTrait;
#[\PHPUnit\Framework\Attributes\DataProvider('dataProvider')]
public function testMarketplaceDetailPage(string $requestedPackage, int $responseCode, string $foundPackageName, string $foundPackageDesc, string $latestVersion = ''): void
{
/** @var MockHandler $handlerStack */
$handlerStack = $this->getClientMockHandler();
$handlerStack->append(
new Response(SymfonyResponse::HTTP_OK, [], file_get_contents(__DIR__.'/../../ApiResponse/allowlist.json')), // Getting Allow list from Github API.
new Response(SymfonyResponse::HTTP_OK, [], file_get_contents(__DIR__.'/../../ApiResponse/detail.json')) // Getting package detail from Packagist API.
);
/** @var Allowlist $allowlist */
$allowlist = static::getContainer()->get('marketplace.service.allowlist');
$allowlist->clearCache();
$this->client->request('GET', "s/marketplace/detail/{$requestedPackage}");
$responseContent = $this->client->getResponse()->getContent();
Assert::assertSame($responseCode, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
Assert::assertStringContainsString($foundPackageDesc, $responseContent);
Assert::assertStringContainsString($foundPackageName, $responseContent);
Assert::assertStringContainsString($latestVersion, $responseContent);
}
/**
* @return iterable<array<string|int>>
*/
public static function dataProvider(): iterable
{
// Package that do not exist in the allowlist.
yield [
'mautic/unicorn',
SymfonyResponse::HTTP_NOT_FOUND,
'mautic/unicorn',
'Package &#039;mautic/unicorn&#039; not found in allowlist.',
];
// Package that exists in the allowlist with display name.
yield [
'koco/mautic-recaptcha-bundle',
SymfonyResponse::HTTP_OK,
'KocoCaptcha',
'This plugin brings reCAPTCHA integration to mautic.',
'<a href="https://github.com/KonstantinCodes/mautic-recaptcha/releases/tag/3.0.1" id="latest-version" target="_blank" rel="noopener noreferrer">',
];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Mautic\MarketplaceBundle\Tests\Functional\Controller;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Response;
use Mautic\CoreBundle\Test\Guzzle\ClientMockTrait;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\MarketplaceBundle\Service\Allowlist;
use Mautic\MarketplaceBundle\Service\Config;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
final class ListControllerTest extends MauticMysqlTestCase
{
use ClientMockTrait;
protected function setUp(): void
{
if ('testMarketplaceListTableWithNoAllowList' === $this->name()) {
$this->configParams[Config::MARKETPLACE_ALLOWLIST_URL] = '0'; // Empty string results in null for some reason.
}
parent::setUp();
}
public function testMarketplaceListTableWithNoAllowList(): void
{
/** @var MockHandler $handlerStack */
$handlerStack = $this->getClientMockHandler();
$handlerStack->append(
new Response(SymfonyResponse::HTTP_OK, [], file_get_contents(__DIR__.'/../../ApiResponse/list.json')) // Getting the package list from Packagist API.
);
/** @var Allowlist $allowlist */
$allowlist = static::getContainer()->get('marketplace.service.allowlist');
$allowlist->clearCache();
$crawler = $this->client->request('GET', 's/marketplace');
Assert::assertTrue($this->client->getResponse()->isOk(), $this->client->getResponse()->getContent());
Assert::assertSame(
[
'Mautic Saelos Bundle',
'Mautic Recaptcha Bundle',
'Mautic Ldap Auth Bundle',
'Mautic Referrals Bundle',
'Mautic Do Not Contact Extras Bundle',
],
array_map(
fn (string $dirtyPackageName) => trim($dirtyPackageName),
$crawler->filter('#marketplace-packages-table .package-name a')->extract(['_text'])
)
);
}
public function testMarketplaceListTableWithAllowList(): void
{
$mockResults = json_decode(file_get_contents(__DIR__.'/../../ApiResponse/list.json'), true)['results'];
/** @var MockHandler $handlerStack */
$handlerStack = $this->getClientMockHandler();
$handlerStack->append(
new Response(SymfonyResponse::HTTP_OK, [], file_get_contents(__DIR__.'/../../ApiResponse/allowlist.json')), // Getting Allow list from Github API.
new Response(SymfonyResponse::HTTP_OK, [], json_encode(['results' => [$mockResults[1]]])), // mautic-recaptcha-bundle
new Response(SymfonyResponse::HTTP_OK, [], json_encode(['results' => [$mockResults[3]]])), // mautic-referrals-bundle
);
/** @var Allowlist $allowlist */
$allowlist = static::getContainer()->get('marketplace.service.allowlist');
$allowlist->clearCache();
$crawler = $this->client->request('GET', 's/marketplace');
Assert::assertTrue($this->client->getResponse()->isOk(), $this->client->getResponse()->getContent());
Assert::assertSame(
[
'KocoCaptcha',
'Mautic Referrals Bundle',
],
array_map(
fn (string $dirtyPackageName) => trim($dirtyPackageName),
$crawler->filter('#marketplace-packages-table .package-name a')->extract(['_text'])
)
);
}
}

View File

@@ -0,0 +1,64 @@
mautic.marketplace.marketplace="Marketplace"
marketplace.title="Marketplace <sup>BETA</sup>"
marketplace.beta.warning="<strong>Heads up!</strong> This is a preview of the Mautic Marketplace. Some things might not work as expected yet. <a target='_blank' href='https://docs.mautic.org/en/marketplace'>[Read more]</a>"
marketplace.composer.required="<strong>Heads up!</strong> Technical setup required to install or update plugins. <a target='_blank' href='https://mau.tc/switch-to-composer'>Send these instructions to a developer.</a>"
mautic.marketplace.permissions.header="Marketplace Permissions"
mautic.marketplace.permissions.packages="Packages - User has access to"
marketplace.vendor="Vendor"
marketplace.downloads="Downloads"
marketplace.favers="Stars"
marketplace.package.github.stars="Stars"
marketplace.package.github.watchers="Watchers"
marketplace.package.github.forks="Forks"
marketplace.package.github.open.issues="Open issues"
marketplace.package.dependents="Dependent packages"
marketplace.package.suggesters="Suggested by other packages"
marketplace.package.total.downloads="Total downloads"
marketplace.package.monthly.downloads="Monthly downloads"
marketplace.package.daily.downloads="Daily downloads"
marketplace.package.version="Version"
marketplace.package.homepage="Homepage"
marketplace.package.create.date="Package created"
marketplace.package.maintainers="Package maintainers"
marketplace.package.license="License"
marketplace.package.issue.tracker="Issue tracker"
marketplace.package.wiki="Documentation"
marketplace.package.version.release.date="Release date"
marketplace.package.required.packages="Required packages"
marketplace.package.keywords="Keywords"
marketplace.other.packages="Other packages by %name%"
marketplace.package.repository="Repository"
marketplace.package.cli.install="Install via CLI"
marketplace.package.cli.install.descr="Installing a plugin via Command Line Interface is recommended. To install this plugin, execute this command: <code>bin/console mautic:marketplace:install %vendor%/%package%</code>"
marketplace.package.latest.stable.version="Latest Stable Version"
marketplace.package.last_updated="Last updated"
marketplace.package.all.versions="All Versions"
marketplace.package.maintainers="Maintainers"
marketplace.package.github.info="GitHub Info"
marketplace.package.packagist.info="Packagist Info"
marketplace.package.install="Install"
marketplace.package.type="Type"
marketplace.package.type.plugin="Plugin"
marketplace.package.type.theme="Theme"
marketplace.package.remove="Remove"
marketplace.package.install.failed="Installation of the package has failed. Please check Mautic's logs for more details."
marketplace.package.install.already.installed="This package is already installed on the system."
marketplace.package.install.html.failed="Something went wrong while installing <strong>%packagename%</strong>. This is the error:"
marketplace.package.install.html.in.progress="<strong>%packagename%</strong> is being installed. This might take a while..."
marketplace.package.install.html.success="Successfully installed <strong>%packagename%</strong>!"
marketplace.package.install.html.success.continue="Go to the plugin page to activate the plugin"
marketplace.package.request.marketplace_disabled="The marketplace is disabled."
marketplace.package.request.no_permissions="You don't have permission to perform this action."
marketplace.package.request.details.missing="The package vendor or name has not been provided. Please try again."
marketplace.package.cache.clear.failed="Couldn't refresh plugins list. Please ask a developer to check server permissions and try again."
marketplace.package.remove.not.installed="The selected package is not currently installed and can therefore not be removed. Please try again with a different package name."
marketplace.package.remove.failed="Removing the package has failed. Please check Mautic's logs for more details."
marketplace.package.remove.html.failed="Something went wrong while removing <strong>%packagename%</strong>. This is the error:"
marketplace.package.remove.html.in.progress="<strong>%packagename%</strong> is being removed. This might take a while..."
marketplace.package.remove.html.success="Successfully removed <strong>%packagename%</strong>!"
marketplace.package.remove.html.success.continue="Back to Marketplace overview"
marketplace.latest.version.missing="Could not find any version of this package. Please try again later."
marketplace.noresults.tip="There are no packages available for your version of Mautic. Try upgrading to a newer version."
marketplace.clear.cache="Refresh plugins list"
marketplace.clear.cache.tooltip="We save some data to make the Marketplace load faster. Click here to update this saved data."
marketplace.package.details.close="Back to marketplace"