feat: improved caching of api-backed content

This commit is contained in:
2026-08-20 12:11:58 +02:00
parent 87e6857bff
commit baf7b5d94a
7 changed files with 294 additions and 63 deletions
@@ -13,9 +13,17 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
class ApiClient class ApiClient
{ {
/**
* Applied when extension configuration does not override them. Seconds.
*/
private const DEFAULT_TIMEOUT = 5;
private const DEFAULT_MAX_DURATION = 15;
public const CALENDAR_MODE_DAYS = 'days'; public const CALENDAR_MODE_DAYS = 'days';
public const CALENDAR_MODE_RANGES = 'ranges'; public const CALENDAR_MODE_RANGES = 'ranges';
private ?array $configuration = null;
public function getCalendar( public function getCalendar(
$hotelCode, $hotelCode,
\DateTimeInterface $dateFrom, \DateTimeInterface $dateFrom,
@@ -84,7 +92,7 @@ class ApiClient
private function getHttpClient(): HttpClientInterface private function getHttpClient(): HttpClientInterface
{ {
$config = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('ep_products'); $config = $this->getConfiguration();
$baseUrl = trim((string)($config['bpnConnectApiBaseUrl'] ?? '')); $baseUrl = trim((string)($config['bpnConnectApiBaseUrl'] ?? ''));
$apiKey = trim((string)($config['bpnConnectApiKey'] ?? '')); $apiKey = trim((string)($config['bpnConnectApiKey'] ?? ''));
@@ -95,10 +103,38 @@ class ApiClient
throw new ApiException('Bpn Connect API key is not configured.'); throw new ApiException('Bpn Connect API key is not configured.');
} }
return HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', [ $options = [
'headers' => [ 'headers' => [
'X-API-KEY' => $apiKey, 'X-API-KEY' => $apiKey,
], ],
]); ];
// Without these the client falls back to PHP's default_socket_timeout (commonly 60s),
// so a stalled API pins a PHP worker for a minute per request. The defaults live in
// code rather than in ext_conf_template.txt: ExtensionConfiguration::get() returns the
// stored configuration verbatim and only syncs the template when an extension has no
// configuration at all, so a newly added template key is absent until someone saves
// extension configuration by hand. Falling back to 0 there would silently mean "no
// timeout" - the opposite of what this guard is for. An explicit 0 still disables.
$timeout = (int)($config['bpnConnectApiTimeout'] ?? self::DEFAULT_TIMEOUT);
if ($timeout > 0) {
$options['timeout'] = $timeout;
}
$maxDuration = (int)($config['bpnConnectApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION);
if ($maxDuration > 0) {
$options['max_duration'] = $maxDuration;
}
return HttpClient::createForBaseUri(rtrim($baseUrl, '/') . '/', $options);
}
private function getConfiguration(): array
{
if (null === $this->configuration) {
$this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products');
}
return $this->configuration;
} }
} }
@@ -34,6 +34,7 @@ use EP\EpProducts\MyEP\ApiException;
use EP\EpProducts\Utility\DateUtility; use EP\EpProducts\Utility\DateUtility;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
class AjaxCalendarController extends ActionController class AjaxCalendarController extends ActionController
{ {
@@ -53,6 +54,8 @@ class AjaxCalendarController extends ActionController
public function priceCalendarAction(Hotel $hotel, int $months = 12): void public function priceCalendarAction(Hotel $hotel, int $months = 12): void
{ {
$hotelCode = trim($hotel->getCode() ?? ''); $hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$dateRange = DateUtility::getDateRange(null, null, $months); $dateRange = DateUtility::getDateRange(null, null, $months);
$today = new \DateTimeImmutable('today'); $today = new \DateTimeImmutable('today');
@@ -62,70 +65,63 @@ class AjaxCalendarController extends ActionController
if ($hotelCode !== '') { if ($hotelCode !== '') {
try { try {
$cacheIdentifier = 'myep_price_calendar_' . sha1(implode('|', [ // This action is cacheable, so the rendered markup is held in the page cache
// for config.cache_period. An additional application-level cache here would
// key on exactly the same granularity (hotel + date range) and only stack a
// second TTL on top, pushing worst-case staleness past the freshness budget.
$rawData = $this->myEpClient->getPriceConfigData(
$hotelCode, $hotelCode,
$dateRange['period']->getStartDate()->format('Y-m-d'), $dateRange['period']->getStartDate(),
$dateRange['period']->getEndDate()->format('Y-m-d'), $dateRange['period']->getEndDate()
])); );
$cachedData = $this->cache->get($cacheIdentifier);
if (false !== $cachedData) { $availableDates = [];
$priceDataByDate = $cachedData; foreach ($rawData as $item) {
} else { if (($item['status'] ?? '') === 'OK') {
$rawData = $this->myEpClient->getPriceConfigData( $availableDates[$item['date']] = true;
$hotelCode, }
$dateRange['period']->getStartDate(), }
$dateRange['period']->getEndDate()
);
$availableDates = []; foreach ($rawData as $item) {
foreach ($rawData as $item) { $date = $item['date'];
if (($item['status'] ?? '') === 'OK') { $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
$availableDates[$item['date']] = true; if ($dt < $today) {
} continue;
} }
foreach ($rawData as $item) { $prevDate = $dt->modify('-1 day')->format('Y-m-d');
$date = $item['date']; $isAvailable = ($item['status'] ?? '') === 'OK';
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $date); $prevAvailable = isset($availableDates[$prevDate]);
if ($dt < $today) {
continue;
}
$prevDate = $dt->modify('-1 day')->format('Y-m-d'); if ($isAvailable) {
$isAvailable = ($item['status'] ?? '') === 'OK'; $dayStatus = $prevAvailable ? 'ok' : 'blocked-to-ok';
$prevAvailable = isset($availableDates[$prevDate]); } elseif ($prevAvailable) {
$dayStatus = 'checkout-only';
if ($isAvailable) { } else {
$dayStatus = $prevAvailable ? 'ok' : 'blocked-to-ok'; $dayStatus = 'blocked';
} elseif ($prevAvailable) {
$dayStatus = 'checkout-only';
} else {
$dayStatus = 'blocked';
}
$priceDataByDate[$date] = [
'status' => $dayStatus,
'available' => $isAvailable ? 1 : 0,
'tooltip' => $this->buildTooltip($dayStatus, $item),
'pricePerNight' => $item['pricePerNight'] ?? null,
'defaultPricePerNight' => $item['defaultPricePerNight'] ?? null,
'type' => $item['type'] ?? null,
'currency' => $item['currency'] ?? null,
'includedPax' => $item['includedPax'] ?? null,
'minNights' => (int)($item['minNights'] ?? 0),
];
} }
$this->cache->set($cacheIdentifier, $priceDataByDate, [], self::CACHE_TTL); $priceDataByDate[$date] = [
'status' => $dayStatus,
'available' => $isAvailable ? 1 : 0,
'tooltip' => $this->buildTooltip($dayStatus, $item),
'pricePerNight' => $item['pricePerNight'] ?? null,
'defaultPricePerNight' => $item['defaultPricePerNight'] ?? null,
'type' => $item['type'] ?? null,
'currency' => $item['currency'] ?? null,
'includedPax' => $item['includedPax'] ?? null,
'minNights' => (int)($item['minNights'] ?? 0),
];
} }
} catch (ApiException $e) { } catch (ApiException $e) {
$error = true; $error = true;
$errorMessage = $e->getMessage(); $errorMessage = $e->getMessage();
$this->disablePageCache();
} }
} else { } else {
$error = true; $error = true;
$errorMessage = 'Missing hotel code'; $errorMessage = 'Missing hotel code';
$this->disablePageCache();
} }
$this->view->assignMultiple([ $this->view->assignMultiple([
@@ -143,6 +139,7 @@ class AjaxCalendarController extends ActionController
$apiDateFrom = $dateRange['period']->getStartDate(); $apiDateFrom = $dateRange['period']->getStartDate();
$apiDateTo = $dateRange['period']->getEndDate(); $apiDateTo = $dateRange['period']->getEndDate();
$hotelCode = trim($hotel->getCode() ?? ''); $hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$priceConfig = [ $priceConfig = [
'data' => [], 'data' => [],
@@ -155,6 +152,7 @@ class AjaxCalendarController extends ActionController
} catch (ApiException $e) { } catch (ApiException $e) {
$priceConfig['success'] = false; $priceConfig['success'] = false;
$priceConfig['error'] = $e->getMessage(); $priceConfig['error'] = $e->getMessage();
$this->disablePageCache();
} }
$this->view->assign('priceConfig', $priceConfig); $this->view->assign('priceConfig', $priceConfig);
@@ -164,6 +162,7 @@ class AjaxCalendarController extends ActionController
{ {
$year = $year ?? (new \DateTimeImmutable())->format('Y'); $year = $year ?? (new \DateTimeImmutable())->format('Y');
$hotelCode = trim($hotel->getCode() ?? ''); $hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$priceTable = [ $priceTable = [
'data' => [], 'data' => [],
@@ -176,6 +175,7 @@ class AjaxCalendarController extends ActionController
} catch (ApiException $e) { } catch (ApiException $e) {
$priceTable['success'] = false; $priceTable['success'] = false;
$priceTable['error'] = $e->getMessage(); $priceTable['error'] = $e->getMessage();
$this->disablePageCache();
} }
$this->view->assignMultiple([ $this->view->assignMultiple([
@@ -212,7 +212,7 @@ class AjaxCalendarController extends ActionController
$hotelCode, $hotelCode,
$apiDateFrom, $apiDateFrom,
$apiDateTo, $apiDateTo,
ApiClient::CALENDAR_MODE_DAYS BpnConnectClient::CALENDAR_MODE_DAYS
); );
$cachedCalendar = $this->cache->get($cacheIdentifier); $cachedCalendar = $this->cache->get($cacheIdentifier);
@@ -223,7 +223,7 @@ class AjaxCalendarController extends ActionController
$hotelCode, $hotelCode,
new \DateTimeImmutable($apiDateFrom), new \DateTimeImmutable($apiDateFrom),
new \DateTimeImmutable($apiDateTo), new \DateTimeImmutable($apiDateTo),
ApiClient::CALENDAR_MODE_DAYS BpnConnectClient::CALENDAR_MODE_DAYS
); );
$this->cache->set($cacheIdentifier, $calendar, [], self::CACHE_TTL); $this->cache->set($cacheIdentifier, $calendar, [], self::CACHE_TTL);
} }
@@ -240,6 +240,43 @@ class AjaxCalendarController extends ActionController
]); ]);
} }
/**
* Tag this page-cache entry with the hotel it renders, so the entry can be flushed for
* that hotel alone rather than by clearing the whole page cache and re-warming every
* calendar. Tags are stored alongside the entry by
* TypoScriptFrontendController::setPageCacheContent().
*
* Used manually today, when one hotel's availability needs re-fetching before its hour
* is up:
*
* typo3cms cache:flushtags ep_hotel_<code> --groups pages
*/
private function tagPageCacheForHotel(string $hotelCode): void
{
if ('' === $hotelCode) {
return;
}
if (isset($GLOBALS['TSFE']) && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController) {
$GLOBALS['TSFE']->addCacheTags(['ep_hotel_' . $hotelCode]);
}
}
/**
* The calendar actions are cacheable, so an API failure would otherwise pin the error
* markup in the page cache for the full cache_period. Drop caching for this request so
* the next visitor gets a fresh attempt instead of an hour-old error message.
*
* The second argument must be true: set_no_cache() ignores the call otherwise, because
* $TYPO3_CONF_VARS['FE']['disableNoCacheParameter'] defaults to true.
*/
private function disablePageCache(): void
{
if (isset($GLOBALS['TSFE']) && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController) {
$GLOBALS['TSFE']->set_no_cache('Price calendar data could not be retrieved', true);
}
}
private function buildTooltip(string $status, array $item): string private function buildTooltip(string $status, array $item): string
{ {
if ($status === 'blocked') { if ($status === 'blocked') {
@@ -16,6 +16,8 @@ use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface; use Symfony\Contracts\HttpClient\ResponseInterface;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -23,8 +25,36 @@ class ApiClient implements LoggerAwareInterface
{ {
use LoggerAwareTrait; use LoggerAwareTrait;
/**
* Applied when extension configuration does not override them. Seconds.
*/
private const DEFAULT_TIMEOUT = 5;
private const DEFAULT_MAX_DURATION = 15;
/**
* Cache identifier for the shared OAuth access token. The token is valid for the whole
* installation, so caching it here saves one round trip per request that would otherwise
* have to re-authenticate before it can fetch anything.
*/
private const ACCESS_TOKEN_CACHE_IDENTIFIER = 'myep_api_access_token';
/**
* Safety margin in seconds. A token that is about to expire is treated as expired so it
* cannot lapse between the cache read and the API call that uses it.
*/
private const ACCESS_TOKEN_EXPIRY_MARGIN = 60;
/**
* Fallback lifetime for tokens that do not report an expiry.
*/
private const ACCESS_TOKEN_DEFAULT_TTL = 300;
private static ?AccessToken $accessToken = null; private static ?AccessToken $accessToken = null;
private ?FrontendInterface $cache = null;
private ?array $configuration = null;
/** /**
* @throws ApiException * @throws ApiException
*/ */
@@ -209,14 +239,58 @@ class ApiClient implements LoggerAwareInterface
private function getHttpClient(): HttpClientInterface private function getHttpClient(): HttpClientInterface
{ {
$config = GeneralUtility::makeInstance(ExtensionConfiguration::class) $config = $this->getConfiguration();
->get('ep_products');
static::$accessToken = $this->getAccessToken($config); static::$accessToken = $this->getAccessToken($config);
return HttpClient::createForBaseUri($config['myEpApiBaseUrl'], [ $options = [
'auth_bearer' => static::$accessToken->getToken(), 'auth_bearer' => static::$accessToken->getToken(),
]); ];
// Without these the client falls back to PHP's default_socket_timeout (commonly 60s),
// so a stalled API pins a PHP worker for a minute per request. The defaults live in
// code rather than in ext_conf_template.txt: ExtensionConfiguration::get() returns the
// stored configuration verbatim and only syncs the template when an extension has no
// configuration at all, so a newly added template key is absent until someone saves
// extension configuration by hand. Falling back to 0 there would silently mean "no
// timeout" - the opposite of what this guard is for. An explicit 0 still disables.
$timeout = (int)($config['myEpApiTimeout'] ?? self::DEFAULT_TIMEOUT);
if ($timeout > 0) {
$options['timeout'] = $timeout;
}
$maxDuration = (int)($config['myEpApiMaxDuration'] ?? self::DEFAULT_MAX_DURATION);
if ($maxDuration > 0) {
$options['max_duration'] = $maxDuration;
}
return HttpClient::createForBaseUri($config['myEpApiBaseUrl'], $options);
}
/**
* Resolved lazily rather than injected: this class is also instantiated through Extbase's
* legacy object container (EP\EpTheme\Form\BpnApiFinisher asks for it via injectApiClient),
* and that container resolves constructor arguments by reflection, mapping an interface to
* a class name by stripping the "Interface" suffix (Container::getImplementationClassName()).
* A constructor typed against FrontendInterface therefore fatals there with
* "Class TYPO3\CMS\Core\Cache\Frontend\Frontend does not exist".
*/
private function getCache(): FrontendInterface
{
if (null === $this->cache) {
$this->cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('ep_products_cache');
}
return $this->cache;
}
private function getConfiguration(): array
{
if (null === $this->configuration) {
$this->configuration = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products');
}
return $this->configuration;
} }
/** /**
@@ -228,8 +302,21 @@ class ApiClient implements LoggerAwareInterface
return static::$accessToken; return static::$accessToken;
} }
// The static above only lives for one PHP request, so without this second level every
// single request would re-authenticate before it could fetch any data.
$cachedToken = $this->getCache()->get(self::ACCESS_TOKEN_CACHE_IDENTIFIER);
if (is_array($cachedToken) && isset($cachedToken['access_token'])) {
$token = new AccessToken($cachedToken);
if (false === $token->hasExpired()) {
static::$accessToken = $token;
return static::$accessToken;
}
}
try { try {
static::$accessToken = $this->getProvider($config)->getAccessToken('client_credentials'); static::$accessToken = $this->getProvider($config)->getAccessToken('client_credentials');
$this->cacheAccessToken(static::$accessToken);
} catch (IdentityProviderException $e) { } catch (IdentityProviderException $e) {
$message = sprintf('MyEP OAuth token request failed: %s', $e->getMessage()); $message = sprintf('MyEP OAuth token request failed: %s', $e->getMessage());
$this->logError($message, ['exception' => $e]); $this->logError($message, ['exception' => $e]);
@@ -247,9 +334,32 @@ class ApiClient implements LoggerAwareInterface
return static::$accessToken; return static::$accessToken;
} }
private function cacheAccessToken(AccessToken $token): void
{
$expires = $token->getExpires();
if (null === $expires) {
$lifetime = self::ACCESS_TOKEN_DEFAULT_TTL;
} else {
$lifetime = $expires - time() - self::ACCESS_TOKEN_EXPIRY_MARGIN;
}
if ($lifetime <= 0) {
return;
}
$this->getCache()->set(self::ACCESS_TOKEN_CACHE_IDENTIFIER, $token->jsonSerialize(), [], $lifetime);
}
private function getProvider(array $config): AbstractProvider private function getProvider(array $config): AbstractProvider
{ {
return new GenericProvider([ return new GenericProvider([
// The OAuth round trip goes through the provider's own Guzzle client, not the
// Symfony client configured in getHttpClient(), so it needs its own timeout -
// otherwise the token request hangs indefinitely and the data-request timeout
// never gets a chance to apply. AbstractProvider::getAllowedClientOptions()
// forwards 'timeout' (and 'proxy') into Guzzle; 0 means unlimited there too.
'timeout' => (int)($config['myEpApiTimeout'] ?? self::DEFAULT_TIMEOUT),
'clientId' => $config['myEpApiClientId'], 'clientId' => $config['myEpApiClientId'],
'clientSecret' => $config['myEpApiClientSecret'], 'clientSecret' => $config['myEpApiClientSecret'],
'redirectUri' => null, 'redirectUri' => null,
@@ -144,6 +144,25 @@ tx_epproducts_ajax_json {
xhtml_cleaning = 0 xhtml_cleaning = 0
admPanel = 0 admPanel = 0
debug = 0 debug = 0
# The calendar actions are cacheable, but they render volatile availability data.
# TSFE would otherwise fall back to cacheTimeOutDefault (86400), which would widen
# staleness from one hour to a full day. Keep this aligned with the freshness budget.
cache_period = 3600
# priceCalendarAction renders relative to 'today', so an entry must not outlive the
# day it was rendered on. Near midnight this clips the lifetime below cache_period.
cache_clearAtMidnight = 1
# The server-side page cache can be flushed; a max-age already sitting in a visitor's
# browser cannot. Availability is volatile enough that we keep the speed win on the
# server and make clients revalidate every time - they still hit a warm page cache.
# sendCacheHeaders is off so TSFE does not also emit a contradicting Expires/Pragma.
sendCacheHeaders = 0
additionalHeaders.30.header = Cache-Control: private, no-cache, must-revalidate
# Same reasoning: staticfilecache serves from disk without touching PHP, which is the
# layer with the least predictable invalidation. Not worth it for volatile fragments.
tx_staticfilecache.disableCache = 1
} }
10 = USER 10 = USER
@@ -193,7 +212,6 @@ tx_epproducts_ajax_json {
1 = concept 1 = concept
} }
} }
features.requireCHashArgumentForActionArguments = 0
settings =< plugin.tx_epproducts.settings settings =< plugin.tx_epproducts.settings
persistence =< plugin.tx_epproducts.persistence persistence =< plugin.tx_epproducts.persistence
view =< plugin.tx_epproducts.view view =< plugin.tx_epproducts.view
@@ -22,8 +22,20 @@ myEpApiUrlAuthorize =
# cat=MyEpAPI; type=string; label=MyE&P API token URL # cat=MyEpAPI; type=string; label=MyE&P API token URL
myEpApiUrlToken = myEpApiUrlToken =
# cat=MyEpAPI; type=int; label=MyE&P API inactivity timeout in seconds (0 = PHP default_socket_timeout)
myEpApiTimeout = 5
# cat=MyEpAPI; type=int; label=MyE&P API max total request duration in seconds (0 = unlimited)
myEpApiMaxDuration = 15
# cat=MyEpAPI; type=string; label=Bpn Connect API base URL # cat=MyEpAPI; type=string; label=Bpn Connect API base URL
bpnConnectApiBaseUrl = bpnConnectApiBaseUrl =
# cat=MyEpAPI; type=string; label=Bpn Connect API key # cat=MyEpAPI; type=string; label=Bpn Connect API key
bpnConnectApiKey = bpnConnectApiKey =
# cat=MyEpAPI; type=int; label=Bpn Connect API inactivity timeout in seconds (0 = PHP default_socket_timeout)
bpnConnectApiTimeout = 5
# cat=MyEpAPI; type=int; label=Bpn Connect API max total request duration in seconds (0 = unlimited)
bpnConnectApiMaxDuration = 15
@@ -6,6 +6,15 @@ $boot = function () {
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['ep_products_cache'] = []; $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['ep_products_cache'] = [];
} }
// The cacheable AjaxCalendar actions are only distinguished in the page-cache key when a
// cHash is present - TypoScriptFrontendController::getRelevantParametersForCachingFromPageArguments()
// drops all arguments without one, so every hotel would otherwise collide on a single
// entry and be served another hotel's availability, silently and with no 404 to signal it.
// Every URL is generated by the Uri\Ajax ViewHelper and carries a cHash; requiring it here
// turns a hand-built or truncated URL into a loud 404 instead of quietly wrong data.
// Client-side callers pass these parameters in POST bodies, which cHash never inspects.
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['requireCacheHashPresenceParameters'][] = '^tx_epproducts_ajax';
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['ep_products'] = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['ep_products'] =
\EP\EpProducts\Hooks\Tcemain::class; \EP\EpProducts\Hooks\Tcemain::class;
@@ -278,7 +287,7 @@ $boot = function () {
'AjaxDate' => 'dates', 'AjaxDate' => 'dates',
'AjaxContingent' => 'list,rooms', 'AjaxContingent' => 'list,rooms',
'AjaxTable' => 'datestable,pricetable,pricetableHtml,eventPricetable,daytripRooms', 'AjaxTable' => 'datestable,pricetable,pricetableHtml,eventPricetable,daytripRooms',
'AjaxCalendar' => 'range,priceConfig,priceCalendar,priceTable', 'AjaxCalendar' => 'range',
'AjaxWatchlist' => 'list', 'AjaxWatchlist' => 'list',
'AjaxTeaserPopup' => 'concept', 'AjaxTeaserPopup' => 'concept',
] ]
@@ -311,9 +320,13 @@ $boot = function () {
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'ref'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'ref';
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'pa'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'pa';
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'ep_staging'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'ep_staging';
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'tx_epproducts_ajax[months]'; // NOTE: tx_epproducts_ajax[months|month|year] were previously excluded from the cHash.
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'tx_epproducts_ajax[month]'; // That was harmless while every AjaxCalendar action was non-cacheable, but priceCalendar,
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'tx_epproducts_ajax[year]'; // priceConfig and priceTable are cacheable now, and an excluded parameter is dropped from
// the page-cache key as well - so the 2026 and 2027 price tables shared one entry and the
// second year was served the first year's content. They must take part in the cHash.
// The client-side callers that vary month/year (calendar_controller.js) send them in a
// POST body, which the cHash never inspects, so nothing depends on the exclusion.
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'f'; $GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'f';
$GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['aspects']['PassthroughMapper'] = $GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['aspects']['PassthroughMapper'] =
@@ -358,8 +358,13 @@
</f:section> </f:section>
<f:section name="PriceTable"> <f:section name="PriceTable">
<f:comment><!--
This section renders once per year, so "load" would fire two requests on first
paint that compete with the price calendar for PHP workers. The tables sit below
the fold, so fetch them when they actually come into view.
--></f:comment>
<div hx-get="{ep:uri.ajax(action: 'priceTable', controller: 'AjaxCalendar', extensionName: 'epproducts', pageUid: settings.defaultAjaxUid, arguments: '{hotel: product.calendarHotel, year: year}', format: 'html')}" <div hx-get="{ep:uri.ajax(action: 'priceTable', controller: 'AjaxCalendar', extensionName: 'epproducts', pageUid: settings.defaultAjaxUid, arguments: '{hotel: product.calendarHotel, year: year}', format: 'html')}"
hx-trigger="load"> hx-trigger="revealed">
<f:render section="TableLoadingIndicator"/> <f:render section="TableLoadingIndicator"/>
</div> </div>
</f:section> </f:section>