Commit 814487dc authored by Tino Goratsch's avatar Tino Goratsch

Merge branch 'release/v4.19.0'

parents ad2cd1d6 195ceafc
......@@ -6,14 +6,16 @@
namespace ACP3\Core\Application;
use ACP3\Core\Application\BootstrapCache\Event\Listener\UserContextSubscriber;
use ACP3\Core\Application\BootstrapCache\Event\Listener\StaticAssetsListener;
use ACP3\Core\Application\BootstrapCache\Event\Listener\UserContextListener;
use ACP3\Core\Session\SessionHandlerInterface;
use ACP3\Core\View\Renderer\Smarty\Filters\MoveToBottom;
use FOS\HttpCache\SymfonyCache\CacheInvalidation;
use FOS\HttpCache\SymfonyCache\DebugListener;
use FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache;
use FOS\HttpCache\SymfonyCache\PurgeSubscriber;
use FOS\HttpCache\SymfonyCache\RefreshSubscriber;
use FOS\HttpCache\SymfonyCache\PurgeListener;
use FOS\HttpCache\SymfonyCache\RefreshListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpCache\HttpCache;
use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
use Symfony\Component\HttpKernel\HttpCache\SurrogateInterface;
use Symfony\Component\HttpKernel\HttpKernelInterface;
......@@ -22,10 +24,9 @@ use Symfony\Component\HttpKernel\HttpKernelInterface;
* Class BootstrapCache
* @package ACP3\Core\Application
*/
class BootstrapCache extends EventDispatchingHttpCache
class BootstrapCache extends HttpCache implements CacheInvalidation
{
const JAVASCRIPTS_REGEX_PATTERN = MoveToBottom::ELEMENT_CATCHER_REGEX_PATTERN;
const PLACEHOLDER = '</body>';
use EventDispatchingHttpCache;
/**
* @inheritdoc
......@@ -34,66 +35,29 @@ class BootstrapCache extends EventDispatchingHttpCache
HttpKernelInterface $kernel,
StoreInterface $store,
SurrogateInterface $surrogate = null,
array $options = [])
{
array $options = []
) {
parent::__construct($kernel, $store, $surrogate, $options);
$this->addSubscriber(new UserContextSubscriber([
$this->addSubscriber(new UserContextListener([
'user_hash_uri' => '/widget/users/index/hash/',
'session_name_prefix' => SessionHandlerInterface::SESSION_NAME
]));
$this->addSubscriber(new PurgeSubscriber());
$this->addSubscriber(new RefreshSubscriber());
}
/**
* {@inheritdoc}
*/
public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true)
{
$response = parent::handle($request, $type, $catch);
$this->moveStaticAssetsAround($response);
return $response;
}
/**
* @param Response $response
*/
private function moveStaticAssetsAround(Response $response)
{
$content = $response->getContent();
if (strpos($content, static::PLACEHOLDER) !== false) {
$content = str_replace(
static::PLACEHOLDER,
$this->addElementsFromTemplates($content) . "\n" . static::PLACEHOLDER,
$this->getCleanedUpTemplateOutput($content)
);
$response->setContent($content);
$response->headers->set('Content-Length', strlen($content));
$this->addSubscriber(new PurgeListener());
$this->addSubscriber(new RefreshListener());
$this->addSubscriber(new StaticAssetsListener());
if (isset($options['debug']) && $options['debug']) {
$this->addSubscriber(new DebugListener());
}
}
/**
* @param string $tplOutput
* @return string
*/
private function getCleanedUpTemplateOutput($tplOutput)
{
return preg_replace(static::JAVASCRIPTS_REGEX_PATTERN, '', $tplOutput);
}
/**
* @param string $tplOutput
* @return string
* Made public to allow event listeners to do refresh operations.
*
* {@inheritDoc}
*/
private function addElementsFromTemplates($tplOutput)
public function fetch(Request $request, $catch = false)
{
$matches = [];
preg_match_all(static::JAVASCRIPTS_REGEX_PATTERN, $tplOutput, $matches);
return implode("\n", array_unique($matches[1])) . "\n";
return parent::fetch($request, $catch);
}
}
<?php
/**
* Copyright (c) by the ACP3 Developers.
* See the LICENCE file at the top-level module directory for licencing details.
*/
namespace ACP3\Core\Application\BootstrapCache\Event\Listener;
use ACP3\Core\View\Renderer\Smarty\Filters\MoveToBottom;
use FOS\HttpCache\SymfonyCache\CacheEvent;
use FOS\HttpCache\SymfonyCache\Events;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class StaticAssetsListener implements EventSubscriberInterface
{
const JAVASCRIPTS_REGEX_PATTERN = MoveToBottom::ELEMENT_CATCHER_REGEX_PATTERN;
const PLACEHOLDER = '</body>';
/**
* @inheritdoc
*/
public static function getSubscribedEvents()
{
return [
Events::POST_HANDLE => 'postHandle'
];
}
public function postHandle(CacheEvent $event)
{
$response = $event->getResponse();
$content = $response->getContent();
if (strpos($content, static::PLACEHOLDER) !== false) {
$content = str_replace(
static::PLACEHOLDER,
$this->addElementsFromTemplates($content) . "\n" . static::PLACEHOLDER,
$this->getCleanedUpTemplateOutput($content)
);
$response->setContent($content);
$response->headers->set('Content-Length', strlen($content));
}
}
/**
* @param string $tplOutput
* @return string
*/
private function getCleanedUpTemplateOutput(string $tplOutput): string
{
return preg_replace(static::JAVASCRIPTS_REGEX_PATTERN, '', $tplOutput);
}
/**
* @param string $tplOutput
* @return string
*/
private function addElementsFromTemplates(string $tplOutput): string
{
$matches = [];
preg_match_all(static::JAVASCRIPTS_REGEX_PATTERN, $tplOutput, $matches);
return implode("\n", array_unique($matches[1])) . "\n";
}
}
<?php
/**
* Copyright (c) by the ACP3 Developers.
* See the LICENCE file at the top-level module directory for licencing details.
*/
namespace ACP3\Core\Application\BootstrapCache\Event\Listener;
use ACP3\Modules\ACP3\Users\Model\AuthenticationModel;
use Symfony\Component\HttpFoundation\Request;
/**
* Caching proxy side of the user context handling for the symfony built-in HttpCache.
*
* @see \FOS\HttpCache\SymfonyCache\UserContextSubscriber for the original file as we had to override some logic...
*/
class UserContextListener extends \FOS\HttpCache\SymfonyCache\UserContextListener
{
/**
* Remove unneeded things from the request for user hash generation.
*
* Cleans cookies header to only keep the session identifier cookie and the ACP3 remember me cookie
*
* @param Request $hashLookupRequest
* @param Request $originalRequest
*/
protected function cleanupHashLookupRequest(Request $hashLookupRequest, Request $originalRequest)
{
$authCookie = $originalRequest->cookies->get(AuthenticationModel::AUTH_NAME);
if ($authCookie !== null) {
$hashLookupRequest->cookies->set(AuthenticationModel::AUTH_NAME, $authCookie);
}
parent::cleanupHashLookupRequest($hashLookupRequest, $originalRequest);
}
}
<?php
/**
* Copyright (c) by the ACP3 Developers.
* See the LICENCE file at the top-level module directory for licencing details.
*/
namespace ACP3\Core\Application\BootstrapCache\Event\Listener;
use ACP3\Modules\ACP3\Users\Model\AuthenticationModel;
use FOS\HttpCache\SymfonyCache\CacheEvent;
use FOS\HttpCache\SymfonyCache\Events;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Class UserContextSubscriber
* @package ACP3\Core\Application\BootstrapCache\Event\Listener
* @see \FOS\HttpCache\SymfonyCache\UserContextSubscriber for the original file as we had to override some logic...
*/
class UserContextSubscriber implements EventSubscriberInterface
{
/**
* The options configured in the constructor argument or default values.
*
* @var array
*/
private $options;
/**
* Generated user hash.
*
* @var string
*/
private $userHash;
/**
* When creating this subscriber, you can configure a number of options.
*
* - anonymous_hash: Hash used for anonymous user.
* - user_hash_accept_header: Accept header value to be used to request the user hash to the
* backend application. Must match the setup of the backend application.
* - user_hash_header: Name of the header the user context hash will be stored into. Must
* match the setup for the Vary header in the backend application.
* - user_hash_uri: Target URI used in the request for user context hash generation.
* - user_hash_method: HTTP Method used with the hash lookup request for user context hash generation.
* - session_name_prefix: Prefix for session cookies. Must match your PHP session configuration.
*
* @param array $options Options to overwrite the default options
*
* @throws \InvalidArgumentException if unknown keys are found in $options
*/
public function __construct(array $options = [])
{
$resolver = new OptionsResolver();
$resolver->setDefaults([
'anonymous_hash' => '38015b703d82206ebc01d17a39c727e5',
'user_hash_accept_header' => 'application/vnd.fos.user-context-hash',
'user_hash_header' => 'X-User-Context-Hash',
'user_hash_uri' => '/_fos_user_context_hash',
'user_hash_method' => 'GET',
'session_name_prefix' => 'PHPSESSID',
]);
$this->options = $resolver->resolve($options);
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents()
{
return [
Events::PRE_HANDLE => 'preHandle',
];
}
/**
* Look at the request before it is handled by the kernel.
*
* Adds the user hash header to the request.
*
* Checks if an external request tries tampering with the user context hash mechanism
* to prevent attacks.
*
* @param CacheEvent $event
*/
public function preHandle(CacheEvent $event)
{
$request = $event->getRequest();
if (!$this->isInternalRequest($request)) {
// Prevent tampering attacks on the hash mechanism
if ($request->headers->get('accept') === $this->options['user_hash_accept_header']
|| $request->headers->get($this->options['user_hash_header']) !== null
) {
$event->setResponse(new Response('', 400));
return;
}
if ($request->isMethodSafe()) {
$request->headers->set(
$this->options['user_hash_header'],
$this->getUserHash($event->getKernel(), $request)
);
}
}
// let the kernel handle this request.
}
/**
* Remove unneeded things from the request for user hash generation.
*
* Cleans cookies header to only keep the session identifier cookie, so the hash lookup request
* can be cached per session.
*
* @param Request $hashLookupRequest
* @param Request $originalRequest
*/
protected function cleanupHashLookupRequest(Request $hashLookupRequest, Request $originalRequest)
{
$authCookie = $originalRequest->cookies->get(AuthenticationModel::AUTH_NAME);
if ($authCookie !== null) {
$hashLookupRequest->cookies->set(AuthenticationModel::AUTH_NAME, $authCookie);
}
$sessionIds = [];
foreach ($originalRequest->cookies as $name => $value) {
if ($this->isSessionName($name)) {
$sessionIds[$name] = $value;
$hashLookupRequest->cookies->set($name, $value);
}
}
if (count($sessionIds) > 0) {
$hashLookupRequest->headers->set('Cookie', http_build_query($sessionIds, '', '; '));
}
}
/**
* Checks if passed request object is to be considered internal (e.g. for user hash lookup).
*
* @param Request $request
*
* @return bool
*/
private function isInternalRequest(Request $request)
{
return $request->attributes->get('internalRequest', false) === true;
}
/**
* Returns the user context hash for $request.
*
* @param HttpKernelInterface $kernel
* @param Request $request
* @return string
*/
private function getUserHash(HttpKernelInterface $kernel, Request $request)
{
if (isset($this->userHash)) {
return $this->userHash;
}
if ($this->isAnonymous($request)) {
return $this->userHash = $this->options['anonymous_hash'];
}
// Hash lookup request to let the backend generate the user hash
$hashLookupRequest = $this->generateHashLookupRequest($request);
$resp = $kernel->handle($hashLookupRequest);
// Store the user hash in memory for sub-requests (processed in the same thread).
$this->userHash = $resp->headers->get($this->options['user_hash_header']);
return $this->userHash;
}
/**
* Checks if current request is considered anonymous.
*
* @param Request $request
*
* @return bool
*/
private function isAnonymous(Request $request)
{
// You might have to enable rewriting of the Authorization header in your server config or .htaccess:
// RewriteEngine On
// RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
if ($request->server->has('AUTHORIZATION') ||
$request->server->has('HTTP_AUTHORIZATION') ||
$request->server->has('PHP_AUTH_USER')
) {
return false;
}
if ($request->cookies->has(AuthenticationModel::AUTH_NAME)) {
return false;
}
foreach ($request->cookies as $name => $value) {
if ($this->isSessionName($name)) {
return false;
}
}
return true;
}
/**
* Checks if passed string can be considered as a session name, such as would be used in cookies.
*
* @param string $name
*
* @return bool
*/
private function isSessionName($name)
{
return strpos($name, $this->options['session_name_prefix']) === 0;
}
/**
* Generates the request object that will be forwarded to get the user context hash.
*
* @param Request $request
*
* @return Request The request that will return the user context hash value.
*/
private function generateHashLookupRequest(Request $request)
{
$hashLookupRequest = Request::create(
$this->options['user_hash_uri'],
$this->options['user_hash_method'],
[],
[],
[],
$request->server->all()
);
$hashLookupRequest->attributes->set('internalRequest', true);
$hashLookupRequest->headers->set('Accept', $this->options['user_hash_accept_header']);
$this->cleanupHashLookupRequest($hashLookupRequest, $request);
return $hashLookupRequest;
}
}
......@@ -14,7 +14,7 @@ interface BootstrapInterface extends HttpKernelInterface
/**
* Contains the current ACP3 version string
*/
const VERSION = '4.18.0';
const VERSION = '4.19.0';
/**
* Performs some startup checks
......
......@@ -47,7 +47,7 @@ trait CacheResponseTrait
}
$response
->setVary('X-User-Context-Hash')
->setVary('Cookie')
->setMaxAge($lifetime)
->setSharedMaxAge($lifetime);
}
......
<?php
namespace ACP3\Core\Helpers;
use ACP3\Core;
use Cocur\Slugify\Slugify;
/**
......
......@@ -7,6 +7,8 @@ use ACP3\Core\Mailer\MailerMessage;
use ACP3\Core\Settings\SettingsInterface;
use ACP3\Modules\ACP3\System\Installer\Schema;
use InlineStyle\InlineStyle;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
use PHPMailer\PHPMailer\PHPMailer;
use Psr\Log\LoggerInterface;
/**
......@@ -76,7 +78,7 @@ class Mailer
*/
private $mailerMessage;
/**
* @var \PHPMailer
* @var PHPMailer
*/
private $phpMailer;
......@@ -284,7 +286,7 @@ class Mailer
if (!empty($this->recipients)) {
return $this->bcc === true ? $this->sendBcc() : $this->sendTo();
}
} catch (\phpmailerException $e) {
} catch (PHPMailerException $e) {
$this->logger->error($e);
} catch (\Exception $e) {
$this->logger->error($e);
......@@ -316,6 +318,9 @@ class Mailer
}
}
/**
* @throws PHPMailerException
*/
private function addFrom()
{
if (is_array($this->from) === true) {
......@@ -414,6 +419,7 @@ class Mailer
* Special sending logic for bcc only E-mails
*
* @return bool
* @throws PHPMailerException
*/
private function sendBcc()
{
......@@ -483,6 +489,7 @@ class Mailer
* Special sending logic for E-mails without bcc addresses
*
* @return bool
* @throws PHPMailerException
*/
private function sendTo()
{
......@@ -534,7 +541,7 @@ class Mailer
private function configure()
{
if ($this->phpMailer === null) {
$this->phpMailer = new \PHPMailer(true);
$this->phpMailer = new PHPMailer(true);
$settings = $this->config->getSettings(Schema::MODULE_NAME);
......@@ -555,7 +562,7 @@ class Mailer
}
$this->phpMailer->CharSet = 'UTF-8';
$this->phpMailer->Encoding = 'quoted-printable';
$this->phpMailer->WordWrap = 76;
$this->phpMailer->WordWrap = PHPMailer::STD_LINE_LENGTH;
}
return $this;
......
......@@ -48,11 +48,11 @@ class Pagination
/**
* @var int
*/
private $showPreviousNext = 2;
private $showPreviousNext = 1;
/**
* @var int
*/
private $pagesToDisplay = 7;
private $pagesToDisplay = 3;
/**
* @var int
*/
......@@ -129,6 +129,27 @@ class Pagination
return $this;
}
/**
* @return int
*/
private function getPagesToDisplay(): int
{
$pagesToDisplay = $this->pagesToDisplay;
$map = [
$this->canShowNextPageLink(),
$this->canShowPreviousPageLink(),
];
foreach ($map as $result) {
if (!$result) {
$pagesToDisplay++;
}
}
return $pagesToDisplay;
}
/**
* @param int $showFirstLast
* @return $this
......@@ -174,22 +195,22 @@ class Pagination
$this->totalPages = (int)ceil($this->totalResults / $this->resultsPerPage);
$this->setMetaStatements();
$range = $this->calculateRange();
[$rangeStart, $rangeEnd] = $this->calculateRange();
$this->showFirstPageLink($link, $range);
$this->showPreviousPageLink($link);
$this->addFirstPageLink($link, $rangeStart);
$this->addPreviousPageLink($link);
for ($i = (int)$range['start']; $i <= $range['end']; ++$i) {
$this->pagination[] = $this->buildPageNumber(
$i,
$link . ($i > 1 ? 'page_' . $i . '/' : '') . $this->urlFragment,
for ($pageNumber = $rangeStart; $pageNumber <= $rangeEnd; ++$pageNumber) {
$this->addPageNumber(
$pageNumber,
$link . ($pageNumber > 1 ? 'page_' . $pageNumber . '/' : '') . $this->urlFragment,
'',
$this->currentPage === $i
$this->currentPage === $pageNumber
);
}
$this->showNextPageLink($link);
$this->showLastPageLink($link, $range);
$this->addNextPageLink($link);
$this->addLastPageLink($link, $rangeEnd);
}
return $this->pagination;
......@@ -207,107 +228,153 @@ class Pagination
}
/**
* @return array
* @return int[]
*/
private function calculateRange()
private function calculateRange(): array
{
$rangeStart = 1;
$rangeEnd = $this->totalPages;
if ($this->totalPages > $this->pagesToDisplay) {
$center = floor($this->pagesToDisplay / 2);
// Beginn der anzuzeigenden Seitenzahlen
if ($this->currentPage - $center > 0) {
$rangeStart = $this->currentPage - $center;
}
// Ende der anzuzeigenden Seitenzahlen
if ($rangeStart + $this->pagesToDisplay - 1 <= $this->totalPages) {
$rangeEnd = $rangeStart + $this->pagesToDisplay - 1;
}
$pagesToDisplay = $this->getPagesToDisplay();
// Anzuzeigende Seiten immer auf dem Wert von $this->pagesToDisplay halten
if ($rangeEnd - $rangeStart < $this->pagesToDisplay && $rangeEnd - $this->pagesToDisplay > 0) {
$rangeStart = $rangeEnd - $this->pagesToDisplay + 1;
if ($this->totalPages > $pagesToDisplay) {
$center = floor($pagesToDisplay / 2);
$rangeStart = max(1, $this->currentPage - $center);
$rangeEnd = min($this->totalPages, $rangeStart + $pagesToDisplay - 1);
// Anzuzeigende Seiten immer auf dem Wert von $pagesToDisplay halten
if ($rangeEnd === $this->totalPages) {
$rangeStart = min($rangeStart, $rangeEnd - $pagesToDisplay + 1);
}
}
return [
'start' => $rangeStart,
'end' => $rangeEnd
(int)$rangeStart,