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,130 @@
<?php
namespace Mautic\Middleware;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
class CORSMiddleware implements HttpKernelInterface, PrioritizedMiddlewareInterface
{
use ConfigAwareTrait;
public const PRIORITY = 1000;
/**
* @var array
*/
protected $corsHeaders = [
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Headers' => 'Origin, X-Requested-With, Content-Type, Authorization',
'Access-Control-Allow-Methods' => 'PUT, GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => 10 * 60 * 60, // 10 min, max age for Chrome
];
/**
* @var bool
*/
protected $requestOriginIsValid = false;
/**
* @var bool
*/
protected $restrictCORSDomains = true;
/**
* @var array
*/
protected $validCORSDomains = [];
/**
* @var HttpKernelInterface
*/
protected $app;
public function __construct(HttpKernelInterface $app)
{
$this->app = $app;
$this->config = $this->getConfig();
$this->restrictCORSDomains = array_key_exists('cors_restrict_domains', $this->config) ? (bool) $this->config['cors_restrict_domains'] : true;
$this->validCORSDomains = array_key_exists('cors_valid_domains', $this->config) ? (array) $this->config['cors_valid_domains'] : [];
}
public function handle(Request $request, $type = self::MAIN_REQUEST, $catch = true): Response
{
$this->corsHeaders['Access-Control-Allow-Origin'] = $this->getAllowOriginHeaderValue($request);
// Capture all OPTIONS requests
if ('OPTIONS' === $request->getMethod()) {
$response = new Response('', Response::HTTP_NO_CONTENT);
// If this is a valid OPTIONS request, set the CORS headers on the Response and exit.
if (
$this->requestOriginIsValid
&& $request->headers->has('Access-Control-Request-Headers')
&& $request->headers->has('Origin')
) {
foreach ($this->corsHeaders as $header => $value) {
$response->headers->set($header, $value);
}
}
return $response;
}
$response = $this->app->handle($request, $type, $catch);
// Add standard CORS headers to any XHR
if ($request->isXmlHttpRequest()) {
foreach ($this->corsHeaders as $header => $value) {
$response->headers->set($header, $value);
}
}
return $response;
}
/**
* Get the value for the Access-Control-Allow-Origin header
* based on the Request and local configuration options.
*
* @return string|null
*/
private function getAllowOriginHeaderValue(Request $request)
{
$origin = $request->headers->get('Origin');
// If we're not restricting domains, set the header to the request origin
if (!$this->restrictCORSDomains || in_array($origin, $this->validCORSDomains)) {
$this->requestOriginIsValid = true;
return $origin;
}
// Check the domains using shell wildcard patterns
$validCorsDomainFilter = function ($validCorsDomain) use ($origin) {
if (null === $origin) {
return null;
}
return fnmatch($validCorsDomain, $origin, FNM_CASEFOLD);
};
if (array_filter($this->validCORSDomains, $validCorsDomainFilter)) {
$this->requestOriginIsValid = true;
$this->corsHeaders['Vary'] = 'Origin';
return $origin;
}
$this->requestOriginIsValid = false;
return null;
}
public function getPriority()
{
return self::PRIORITY;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Mautic\Middleware;
use Mautic\CoreBundle\ErrorHandler\ErrorHandler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
class CatchExceptionMiddleware implements HttpKernelInterface, PrioritizedMiddlewareInterface
{
public const PRIORITY = 100;
/**
* @var HttpKernelInterface
*/
protected $app;
public function __construct(HttpKernelInterface $app)
{
$this->app = $app;
}
public function handle(Request $request, $type = self::MAIN_REQUEST, $catch = true): Response
{
$content = 'The site is currently offline due to encountering an error. If the problem persists, please contact the system administrator. System administrators, check server logs for errors.';
try {
$response = $this->app->handle($request, $type, $catch);
if ($response instanceof Response) {
return $response;
}
} catch (\Exception $exception) {
$content = ErrorHandler::getHandler()->handleException($exception, true);
}
return new Response($content, 500);
}
public function getPriority()
{
return self::PRIORITY;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Mautic\Middleware;
use Mautic\CoreBundle\Loader\ParameterLoader;
trait ConfigAwareTrait
{
/**
* @var array
*/
protected $config = [];
/**
* @return array
*/
public function getConfig()
{
// Include paths
$root = realpath(__DIR__.'/..');
$configBaseDir = ParameterLoader::getLocalConfigBaseDir($root);
$projectRoot = ParameterLoader::getProjectDirByRoot($root);
/** @var array $paths */
include $root.'/config/paths.php';
$localParameters = [];
$localConfig = ParameterLoader::getLocalConfigFile($root, false);
if (file_exists($localConfig)) {
/** @var $parameters */
include $localConfig;
$localParameters = $parameters;
}
// check for parameter overrides
if (file_exists($configBaseDir.'/config/parameters_local.php')) {
include $configBaseDir.'/config/parameters_local.php';
$localParameters = array_merge($localParameters, $parameters);
}
return $localParameters;
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Mautic\Middleware\Dev;
use Mautic\Middleware\ConfigAwareTrait;
use Mautic\Middleware\PrioritizedMiddlewareInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
class IpRestrictMiddleware implements HttpKernelInterface, PrioritizedMiddlewareInterface
{
use ConfigAwareTrait;
public const PRIORITY = 20;
/**
* @var HttpKernelInterface
*/
protected $app;
/**
* @var array
*/
protected $allowedIps;
public function __construct(HttpKernelInterface $app)
{
$this->app = $app;
$this->allowedIps = ['127.0.0.1', 'fe80::1', '::1'];
$parameters = $this->getConfig();
if (array_key_exists('dev_hosts', $parameters) && is_array($parameters['dev_hosts'])) {
$this->allowedIps = array_merge($this->allowedIps, $parameters['dev_hosts']);
}
if (isset($_SERVER['MAUTIC_CUSTOM_DEV_HOSTS'])) {
$localIps = json_decode($_SERVER['MAUTIC_CUSTOM_DEV_HOSTS'], true);
$this->allowedIps = array_merge($this->allowedIps, $localIps);
}
}
/**
* This check prevents access to debug front controllers
* that are deployed by accident to production servers.
*
* {@inheritdoc}
*/
public function handle(Request $request, $type = self::MAIN_REQUEST, $catch = true): Response
{
if (in_array($request->getClientIp(), $this->allowedIps) || false !== getenv('DDEV_TLD')) {
return $this->app->handle($request, $type, $catch);
}
return new Response('You are not allowed to access this file.', 403);
}
public function getPriority()
{
return self::PRIORITY;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Mautic\Middleware;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
class HSTSMiddleware implements HttpKernelInterface, PrioritizedMiddlewareInterface
{
use ConfigAwareTrait;
public const PRIORITY = 900;
protected bool $enableHSTS;
protected bool $includeDubDomains;
protected bool $preload;
protected int $expireTime;
protected HttpKernelInterface $app;
public function __construct(HttpKernelInterface $app)
{
$this->app = $app;
$this->config = $this->getConfig();
$this->enableHSTS = array_key_exists('headers_sts', $this->config) && (bool) $this->config['headers_sts'];
$this->includeDubDomains = array_key_exists('headers_sts_subdomains', $this->config) && (bool) $this->config['headers_sts_subdomains'];
$this->preload = array_key_exists('headers_sts_preload', $this->config) && (bool) $this->config['headers_sts_preload'];
$this->expireTime = $this->config['headers_sts_expire_time'] ?? 60;
}
public function handle(Request $request, $type = self::MAIN_REQUEST, $catch = true): Response
{
$response = $this->app->handle($request, $type, $catch);
// Do not include the header in the sub-request response
if (self::MAIN_REQUEST !== $type) {
return $response;
}
if ($this->enableHSTS && $this->expireTime) {
$value = 'max-age='.$this->expireTime.($this->includeDubDomains ? '; includeSubDomains' : '').($this->preload ? '; preload' : '');
$response->headers->set('Strict-Transport-Security', $value);
}
return $response;
}
public function getPriority(): int
{
return self::PRIORITY;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Mautic\Middleware;
use Mautic\CoreBundle\Cache\MiddlewareCacheWarmer;
class MiddlewareBuilder
{
private \AppKernel $app;
private string $cacheFile;
private \SplPriorityQueue $specs;
public function __construct(\AppKernel $app)
{
$this->app = $app;
$this->cacheFile = sprintf('%s/middlewares.cache.php', $app->getCacheDir());
$this->specs = new \SplPriorityQueue();
}
public function resolve(): StackedHttpKernel
{
$this->loadMiddlewares();
$app = $this->app;
$middlewares = [$app];
foreach ($this->specs as $spec) {
$app = $spec->newInstanceArgs([$app]);
array_unshift($middlewares, $app);
}
return new StackedHttpKernel($app, $middlewares);
}
private function loadMiddlewares(): void
{
if (!$this->hasCacheFile()) {
$this->warmUpCacheCommand();
}
$this->loadCacheFile();
}
private function warmUpCacheCommand(): void
{
$middlewareCacheWarmer = new MiddlewareCacheWarmer($this->app->getEnvironment());
$middlewareCacheWarmer->warmUp($this->app->getCacheDir());
}
private function hasCacheFile(): bool
{
return file_exists($this->cacheFile);
}
private function loadCacheFile(): void
{
/** @var array $middlewares */
$middlewares = include $this->cacheFile;
foreach ($middlewares as $middleware) {
$this->push($middleware);
}
}
private function push(string $middlewareClass): void
{
try {
$reflection = new \ReflectionClass($middlewareClass);
$priority = $reflection->getConstant('PRIORITY');
$this->specs->insert($reflection, $priority);
} catch (\ReflectionException $e) {
/* If there's an error getting the kernel class, it's
* an invalid middleware. If it's invalid, don't push
* it to the stack
*/
}
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Mautic\Middleware;
interface PrioritizedMiddlewareInterface
{
/**
* Get the middleware's priority.
*
* @return int
*/
public function getPriority();
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Mautic\Middleware;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\TerminableInterface;
/**
* Provides a stacked HTTP kernel.
*
* Copied from https://github.com/stackphp/builder/ with added compatibility
* for Symfony 6.
*
* @see https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21StackMiddleware%21StackedHttpKernel.php/class/StackedHttpKernel/11.x
* @see \Drupal\Core\DependencyInjection\Compiler\StackedKernelPass
*/
class StackedHttpKernel implements HttpKernelInterface, TerminableInterface
{
/**
* The decorated kernel.
*
* @var HttpKernelInterface
*/
private $kernel;
/**
* A set of middlewares that are wrapped around this kernel.
*
* @var array
*/
private $middlewares = [];
/**
* Constructs a stacked HTTP kernel.
*
* @param HttpKernelInterface $kernel
* The decorated kernel
* @param array $middlewares
* An array of previous middleware services
*/
public function __construct(HttpKernelInterface $kernel, array $middlewares)
{
$this->kernel = $kernel;
$this->middlewares = $middlewares;
}
/**
* {@inheritdoc}
*/
public function handle(Request $request, $type = HttpKernelInterface::MAIN_REQUEST, $catch = true): Response
{
return $this->kernel
->handle($request, $type, $catch);
}
/**
* {@inheritdoc}
*/
public function terminate(Request $request, Response $response): void
{
$previous = null;
foreach ($this->middlewares as $kernel) {
// If the previous kernel was terminable we can assume this middleware
// has already been called.
if (!$previous instanceof TerminableInterface && $kernel instanceof TerminableInterface) {
$kernel->terminate($request, $response);
}
$previous = $kernel;
}
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace Mautic\Middleware\Tests\Dev;
use Mautic\Middleware\Dev\IpRestrictMiddleware;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
class IpRestrictMiddlewareTest extends \PHPUnit\Framework\TestCase
{
private mixed $originalDdevTldValue;
public function setUp(): void
{
$this->originalDdevTldValue = getenv('DDEV_TLD');
putenv('DDEV_TLD');
parent::setUp();
}
public function tearDown(): void
{
putenv('DDEV_TLD='.$this->originalDdevTldValue);
parent::tearDown();
}
public function testWorkflowWithLocalhostIp(): void
{
$inputRequest = new Request();
$inputRequest->server->set('REMOTE_ADDR', '127.0.0.1'); // 127.0.0.1 is always allowed.
$httpKernel = new class implements HttpKernelInterface {
public function __construct()
{
}
public function handle(Request $request, $type = HttpKernelInterface::MAIN_REQUEST, $catch = true): Response
{
return new Response();
}
};
$middleware = new IpRestrictMiddleware($httpKernel);
$response = $middleware->handle($inputRequest);
Assert::assertSame(Response::HTTP_OK, $response->getStatusCode());
}
public function testWorkflowWithDisallowedIp(): void
{
$inputRequest = new Request();
$inputRequest->server->set('REMOTE_ADDR', 'unallowed.ip.address');
$httpKernel = new class implements HttpKernelInterface {
public $handleWasCalled = false;
public function __construct()
{
}
public function handle(Request $request, $type = HttpKernelInterface::MAIN_REQUEST, $catch = true): Response
{
$this->handleWasCalled = true;
return new Response();
}
};
$middleware = new IpRestrictMiddleware($httpKernel);
$response = $middleware->handle($inputRequest);
Assert::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode());
Assert::assertFalse($httpKernel->handleWasCalled);
}
public function testWorkflowWithConfiguredIp(): void
{
// Remember original custom_dev_hosts value so we could return it afterwards.
$originalDevHostsValue = $_SERVER['MAUTIC_CUSTOM_DEV_HOSTS'] ?? '[]';
$_SERVER['MAUTIC_CUSTOM_DEV_HOSTS'] = '["configured.ip.address"]';
$inputRequest = new Request();
$inputRequest->server->set('REMOTE_ADDR', 'configured.ip.address');
$httpKernel = new class($inputRequest) implements HttpKernelInterface {
public function __construct()
{
}
public function handle(Request $request, $type = HttpKernelInterface::MAIN_REQUEST, $catch = true): Response
{
return new Response();
}
};
$middleware = new IpRestrictMiddleware($httpKernel);
$response = $middleware->handle($inputRequest);
Assert::assertSame(Response::HTTP_OK, $response->getStatusCode());
// Set the original value back.
$_SERVER['MAUTIC_CUSTOM_DEV_HOSTS'] = $originalDevHostsValue;
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace Mautic\Middleware\Tests;
use Mautic\CoreBundle\Test\AbstractMauticTestCase;
use Mautic\Middleware\HSTSMiddleware;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\ExpectationFailedException as PHPUnitException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class HSTSMiddlewareTest extends AbstractMauticTestCase
{
public const HSTS_KEY = 'strict-transport-security';
protected \ReflectionProperty $addHSTS;
protected \ReflectionProperty $includeDubDomains;
protected \ReflectionProperty $preload;
protected HSTSMiddleware $middleware;
protected \ReflectionClass $middlewareReflection;
/**
* @throws \ReflectionException
*/
protected function setUp(): void
{
parent::setUp();
$this->middleware = new HSTSMiddleware($this->client->getKernel());
$this->middlewareReflection = new \ReflectionClass($this->middleware);
$this->addHSTS = $this->middlewareReflection->getProperty('enableHSTS');
$this->addHSTS->setAccessible(true);
$this->includeDubDomains = $this->middlewareReflection->getProperty('includeDubDomains');
$this->includeDubDomains->setAccessible(true);
$this->preload = $this->middlewareReflection->getProperty('preload');
$this->preload->setAccessible(true);
}
protected function testResponseHeaders(): void
{
$response = $this->getMiddlewareResponse();
Assert::assertNotEmpty($response->headers);
}
public function testHSTSEnabled(): void
{
$this->setHSTS(true);
$response = $this->getMiddlewareResponse();
Assert::assertTrue(
$response->headers->has(self::HSTS_KEY),
'Strict-Transport-Security is enabled but is missing from the response headers'
);
}
public function testHSTSDisabled(): void
{
$this->setHSTS(false);
$response = $this->getMiddlewareResponse();
Assert::assertFalse(
$response->headers->has(self::HSTS_KEY),
'Strict-Transport-Security is disabled but is present in response headers'
);
}
public function testIncludeSubdomainsEnabled(): void
{
$needle = 'includeSubDomains';
$this->setHSTS(true);
$this->setIncludeDubDomainsValue(true);
$response = $this->getMiddlewareResponse();
Assert::assertStringContainsString(
$needle,
$response->headers->get(self::HSTS_KEY),
'Option include Subdomains is enabled but is missing from the HSTS value'
);
}
public function testIncludeSubdomainsDisabled(): void
{
$needle = 'includeSubDomains';
$this->setHSTS(true);
$this->setIncludeDubDomainsValue(false);
$response = $this->getMiddlewareResponse();
Assert::assertStringNotContainsStringIgnoringCase(
$needle,
$this->getHSTSValue($response),
'Option include Subdomains is disabled but is present in HSTS value'
);
}
public function testPreloadEnabled(): void
{
$needle = 'preload';
$this->setHSTS(true);
$this->setPreloadValue(true);
$response = $this->getMiddlewareResponse();
Assert::assertStringContainsString(
$needle,
$response->headers->get(self::HSTS_KEY),
'Option preload is enabled but is missing from the HSTS value'
);
}
public function testPreloadDisabled(): void
{
$needle = 'preload';
$this->setHSTS(true);
$this->setPreloadValue(false);
$response = $this->getMiddlewareResponse();
Assert::assertStringNotContainsStringIgnoringCase(
$needle,
$this->getHSTSValue($response),
'Option preload is disabled but is present in HSTS value'
);
}
/**
* @throws \ReflectionException
*/
public function testExpireTime(): void
{
$this->setHSTS(true);
$expireTimeValue = 12345;
$expireTime = $this->middlewareReflection->getProperty('expireTime');
$expireTime->setAccessible(true);
$expireTime->setValue($this->middleware, $expireTimeValue);
$response = $this->getMiddlewareResponse();
Assert::assertMatchesRegularExpression(
'/max-age='.$expireTimeValue.'(; includeSubDomains)?/',
$this->getHSTSValue($response),
'Expire time does not match the configuration'
);
}
private function setHSTS(bool $value): void
{
$this->addHSTS->setValue($this->middleware, $value);
}
private function setIncludeDubDomainsValue(bool $value): void
{
$this->includeDubDomains->setValue($this->middleware, $value);
}
private function setPreloadValue(bool $value): void
{
$this->preload->setValue($this->middleware, $value);
}
private function getHSTSValue(Response $response): string
{
return $response->headers->get(self::HSTS_KEY) ?? '';
}
private function getMiddlewareResponse(): Response
{
try {
return $this->middleware->handle(Request::create('s/login', Request::METHOD_GET));
} catch (\Exception $e) {
throw new PHPUnitException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Mautic\Middleware;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
class TrustMiddleware implements HttpKernelInterface, PrioritizedMiddlewareInterface
{
use ConfigAwareTrait;
public const PRIORITY = 0;
/**
* @var HttpKernelInterface
*/
private $app;
public function __construct(HttpKernelInterface $app)
{
$this->app = $app;
}
public function getPriority(): int
{
return self::PRIORITY;
}
public function handle(Request $request, $type = self::MAIN_REQUEST, $catch = true): Response
{
$config = $this->getConfig();
if (!empty($config['trusted_proxies'])) {
Request::setTrustedProxies($config['trusted_proxies'], Request::getTrustedHeaderSet());
}
if (!empty($config['trusted_hosts'])) {
Request::setTrustedHosts($config['trusted_hosts']);
}
return $this->app->handle($request, $type, $catch);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Mautic\Middleware;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
class VersionCheckMiddleware implements HttpKernelInterface, PrioritizedMiddlewareInterface
{
public const PRIORITY = 10;
/**
* @var HttpKernelInterface
*/
protected $app;
/**
* @var string
*/
private $minimumPHPVersion;
/**
* @var string
*/
private $maximumPHPVersion;
public function __construct(HttpKernelInterface $app)
{
$this->app = $app;
$metadata = json_decode(
file_get_contents(__DIR__.'/../release_metadata.json'),
true
);
$this->minimumPHPVersion = $metadata['minimum_php_version'];
$this->maximumPHPVersion = $metadata['maximum_php_version'];
}
/**
* Check Minimum / Maximum PHP versions.
*
* {@inheritdoc}
*/
public function handle(Request $request, $type = self::MAIN_REQUEST, $catch = true): Response
{
// Are we running the minimum version?
if (version_compare(PHP_VERSION, $this->minimumPHPVersion, 'lt')) {
return new Response('Your server does not meet the minimum PHP requirements. Mautic requires PHP version '.$this->minimumPHPVersion.' while your server has '.PHP_VERSION.'. Please contact your host to update your PHP installation.', 500);
}
// Are we running a version newer than what Mautic supports?
if (version_compare(PHP_VERSION, $this->maximumPHPVersion, 'gt')) {
return new Response('Mautic does not support PHP version '.PHP_VERSION.' at this time. To use Mautic, you will need to downgrade to an earlier version.', 500);
}
return $this->app->handle($request, $type, $catch);
}
public function getPriority()
{
return self::PRIORITY;
}
}