diff --git a/public/typo3conf/ext/ep_products/Classes/ContentFeed/ContentFeedApi.php b/public/typo3conf/ext/ep_products/Classes/ContentFeed/ContentFeedApi.php new file mode 100644 index 00000000..e8753ba5 --- /dev/null +++ b/public/typo3conf/ext/ep_products/Classes/ContentFeed/ContentFeedApi.php @@ -0,0 +1,96 @@ +authenticator = $authenticator; + $this->configuration = $configuration; + $this->manifestBuilder = $manifestBuilder; + $this->cache = $cacheManager->getCache('ep_products_cache'); + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + if (!$this->authenticator->isAuthorized($request)) { + return $this->error('Invalid or missing api key', 403); + } + + if ([] === $this->configuration->getSites()) { + // An empty feed would read as "everything was deleted". Refuse instead. + return $this->error('Content feed has no sites configured', 503); + } + + $path = substr($request->getUri()->getPath(), strlen(self::PATH_PREFIX)); + + if ('manifest' === $path) { + return new JsonResponse($this->getManifest(), 200, [], self::JSON_FLAGS); + } + + return $this->error('Not found', 404); + } + + private function getManifest(): array + { + $manifest = $this->cache->get(self::MANIFEST_CACHE_IDENTIFIER); + + if (!is_array($manifest)) { + $manifest = $this->manifestBuilder->build(); + $this->cache->set(self::MANIFEST_CACHE_IDENTIFIER, $manifest, [], self::MANIFEST_CACHE_LIFETIME); + } + + return $manifest; + } + + private function error(string $message, int $status): ResponseInterface + { + return new JsonResponse(['message' => $message], $status, [], self::JSON_FLAGS); + } +} diff --git a/public/typo3conf/ext/ep_products/Classes/ContentFeed/ContentFeedConfiguration.php b/public/typo3conf/ext/ep_products/Classes/ContentFeed/ContentFeedConfiguration.php index 7aa5b486..342485a8 100644 --- a/public/typo3conf/ext/ep_products/Classes/ContentFeed/ContentFeedConfiguration.php +++ b/public/typo3conf/ext/ep_products/Classes/ContentFeed/ContentFeedConfiguration.php @@ -37,6 +37,16 @@ class ContentFeedConfiguration return GeneralUtility::trimExplode(',', (string) ($this->configuration['contentFeedSites'] ?? ''), true); } + /** + * @return int[] language ids; the default language when unset + */ + public function getLanguages(): array + { + $languages = GeneralUtility::intExplode(',', (string) ($this->configuration['contentFeedLanguages'] ?? '0'), true); + + return [] === $languages ? [0] : $languages; + } + /** * @return int[] page uids whose whole subtree stays out of the feed */ diff --git a/public/typo3conf/ext/ep_products/Classes/ContentFeed/ManifestBuilder.php b/public/typo3conf/ext/ep_products/Classes/ContentFeed/ManifestBuilder.php new file mode 100644 index 00000000..23cccf78 --- /dev/null +++ b/public/typo3conf/ext/ep_products/Classes/ContentFeed/ManifestBuilder.php @@ -0,0 +1,351 @@ + ['uid', 'tstamp', 'detail_page'], + 'hotel' => ['uid', 'tstamp', 'detail_page'], + 'region' => ['uid', 'tstamp', 'detail_page'], + 'country' => ['uid', 'tstamp', 'detail_page'], + 'city' => ['uid', 'tstamp', 'detail_page'], + 'concept' => ['uid', 'tstamp', 'detail_page'], + 'news' => ['uid', 'tstamp', 'pid'], + ]; + + /** + * @var PageTree + */ + private $pageTree; + + /** + * @var UrlResolver + */ + private $urlResolver; + + /** + * @var RecordFinder + */ + private $recordFinder; + + /** + * @var ContentFeedConfiguration + */ + private $configuration; + + /** + * @var ConnectionPool + */ + private $connectionPool; + + public function __construct( + PageTree $pageTree, + UrlResolver $urlResolver, + RecordFinder $recordFinder, + ContentFeedConfiguration $configuration, + ConnectionPool $connectionPool + ) { + $this->pageTree = $pageTree; + $this->urlResolver = $urlResolver; + $this->recordFinder = $recordFinder; + $this->configuration = $configuration; + $this->connectionPool = $connectionPool; + } + + public function build(): array + { + $sites = $this->urlResolver->getIncludedSites(); + $records = $this->buildRecordItems(); + + return [ + 'generatedAt' => date('c'), + 'sites' => array_map([$this, 'describeSite'], $sites), + 'items' => array_merge($this->buildPageItems($sites, $this->collectDetailPages()), $records), + ]; + } + + private function describeSite(Site $site): array + { + $languages = []; + + foreach ($site->getLanguages() as $language) { + $languages[] = $language->getTwoLetterIsoCode(); + } + + $title = trim((string) ($site->getConfiguration()['websiteTitle'] ?? '')); + + if ('' === $title) { + $title = trim($site->getDefaultLanguage()->getWebsiteTitle()); + } + + return [ + 'identifier' => $site->getIdentifier(), + 'title' => '' !== $title ? $title : $site->getIdentifier(), + 'base' => (string) $site->getBase(), + 'languages' => $languages, + ]; + } + + /** + * @param Site[] $sites + * @param array $detailPages detailOf entries by page uid + */ + private function buildPageItems(array $sites, array $detailPages): array + { + $contentTimestamps = $this->findContentTimestamps(); + $excluded = array_flip($this->configuration->getExcludedPages()); + $feedLanguages = $this->configuration->getLanguages(); + $items = []; + + foreach ($sites as $site) { + $languageIds = []; + + foreach ($site->getLanguages() as $language) { + if (in_array($language->getLanguageId(), $feedLanguages, true)) { + $languageIds[$language->getLanguageId()] = $language->getTwoLetterIsoCode(); + } + } + + $context = [ + 'site' => $site, + 'languages' => $languageIds, + 'excluded' => $excluded, + 'contentTimestamps' => $contentTimestamps, + 'detailPages' => $detailPages, + ]; + $breadcrumbs = array_fill_keys(array_keys($languageIds), []); + + $this->walk($site->getRootPageId(), $context, $breadcrumbs, true, $items); + } + + return $items; + } + + /** + * @param array $breadcrumbs titles above this page, by language id + */ + private function walk(int $uid, array $context, array $breadcrumbs, bool $isRoot, array &$items): void + { + $page = $this->pageTree->getPage($uid); + + if (null === $page || isset($context['excluded'][$uid])) { + return; + } + + if (in_array((int) $page['doktype'], self::DOKTYPES_NOT_TRAVERSED, true)) { + return; + } + + $visible = $this->pageTree->isVisible($page); + + if (!$visible && (int) $page['extendToSubpages'] === 1) { + return; + } + + $childBreadcrumbs = $breadcrumbs; + + foreach ($context['languages'] as $languageId => $isoCode) { + $row = $this->getLanguageRow($page, $languageId); + + if ($visible && null !== $row && $this->isPublishable($page, $row, $languageId)) { + $item = $this->buildPageItem($page, $row, $languageId, $isoCode, $breadcrumbs[$languageId], $context); + + if (null !== $item) { + $items[] = $item; + } + } + + if (!$isRoot) { + $source = $row ?? $page; + $childBreadcrumbs[$languageId][] = '' !== trim((string) $source['nav_title']) + ? trim((string) $source['nav_title']) + : trim((string) $source['title']); + } + } + + foreach ($this->pageTree->getChildren($uid) as $childUid) { + $this->walk($childUid, $context, $childBreadcrumbs, false, $items); + } + } + + private function getLanguageRow(array $page, int $languageId): ?array + { + if (0 === $languageId) { + return $page; + } + + $overlay = $this->pageTree->getOverlay((int) $page['uid'], $languageId); + + return null !== $overlay && $this->pageTree->isVisible($overlay) ? $overlay : null; + } + + /** + * Mirrors the XML sitemap: standard pages only, nothing marked no_index. If Google is not + * meant to see a page, the chatbot is not either. + */ + private function isPublishable(array $page, array $row, int $languageId): bool + { + if ((int) $page['doktype'] !== self::DOKTYPE_DEFAULT || (int) $row['no_index'] === 1) { + return false; + } + + return 0 !== $languageId || 0 === ((int) $page['l18n_cfg'] & self::L18N_HIDE_DEFAULT_LANGUAGE); + } + + private function buildPageItem(array $page, array $row, int $languageId, string $isoCode, array $breadcrumb, array $context): ?array + { + $uid = (int) $page['uid']; + $url = $this->urlResolver->getPageUrl($uid, $languageId); + + if (null === $url) { + return null; + } + + $modified = max( + (int) $row['tstamp'], + $context['contentTimestamps'][$uid][$languageId] ?? 0, + $context['contentTimestamps'][$uid][-1] ?? 0 + ); + + return [ + 'type' => 'page', + 'uid' => $uid, + 'language' => $isoCode, + 'site' => $context['site']->getIdentifier(), + 'url' => $url, + 'contentUrl' => $this->urlResolver->getPageUrl($uid, $languageId, ['type' => self::CONTENT_PAGE_TYPE]), + 'title' => trim((string) $row['title']), + 'description' => $this->nullIfEmpty((string) $row['description']), + 'breadcrumb' => $breadcrumb, + 'detailOf' => $context['detailPages'][$uid] ?? [], + 'modified' => date('c', $modified), + ]; + } + + /** + * Latest change to any content element, by page and language. Deleted and hidden elements + * count - removing or hiding one changes the page - which is also why SYS_LASTCHANGED is no + * use here: it only moves when the page is rendered. + * + * @return array> + */ + private function findContentTimestamps(): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content'); + $queryBuilder->getRestrictions()->removeAll(); + + $rows = $queryBuilder + ->selectLiteral('pid', 'sys_language_uid', 'MAX(tstamp) AS tstamp') + ->from('tt_content') + ->groupBy('pid', 'sys_language_uid') + ->execute() + ->fetchAllAssociative() + ; + + $timestamps = []; + + foreach ($rows as $row) { + $timestamps[(int) $row['pid']][(int) $row['sys_language_uid']] = (int) $row['tstamp']; + } + + return $timestamps; + } + + /** + * Which records name a page as their detail page. Keyed by the page the record points at, + * not by where a shortcut leads: a shortcut is not in the manifest, and the page it leads to + * usually describes something broader (product detail shortcuts point at region pages). + * + * @return array + */ + private function collectDetailPages(): array + { + $detailPages = []; + + foreach (self::RECORD_FIELDS as $type => $fields) { + if (!in_array('detail_page', $fields, true)) { + continue; + } + + foreach ($this->recordFinder->find($type, ['uid', 'detail_page']) as $uid => $row) { + $pageUid = $this->urlResolver->parsePageReference((string) $row['detail_page']); + + if (null !== $pageUid) { + $detailPages[$pageUid][] = ['type' => $type, 'uid' => $uid]; + } + } + } + + return $detailPages; + } + + private function buildRecordItems(): array + { + $faqTimestamps = $this->recordFinder->findFaqTimestamps(); + $matchcodeTimestamps = $this->recordFinder->findMatchcodeTimestamps(); + $items = []; + + foreach (self::RECORD_FIELDS as $type => $fields) { + foreach ($this->recordFinder->find($type, $fields) as $uid => $row) { + if ('news' === $type) { + $link = $this->urlResolver->getNewsLink((int) $row['uid'], (int) $row['pid']); + + // News has no detail_page of its own: an article stored outside the feed's + // sites (the retired ep-events) is not part of the feed at all. + if (null === $link) { + continue; + } + } else { + $link = $this->urlResolver->getRecordLink((string) $row['detail_page']); + } + + $modified = (int) $row['tstamp']; + + if ('product' === $type) { + $modified = max($modified, $faqTimestamps[$uid] ?? 0); + } + + if ('hotel' === $type) { + $modified = max($modified, $matchcodeTimestamps[$uid] ?? 0); + } + + $items[] = [ + 'type' => $type, + 'uid' => $uid, + 'language' => 'de', + 'site' => $link['site'], + 'url' => $link['url'], + 'modified' => date('c', $modified), + ]; + } + } + + return $items; + } + + private function nullIfEmpty(string $value): ?string + { + $value = trim($value); + + return '' === $value ? null : $value; + } +} diff --git a/public/typo3conf/ext/ep_products/Classes/ContentFeed/PageTree.php b/public/typo3conf/ext/ep_products/Classes/ContentFeed/PageTree.php new file mode 100644 index 00000000..cafdefa7 --- /dev/null +++ b/public/typo3conf/ext/ep_products/Classes/ContentFeed/PageTree.php @@ -0,0 +1,131 @@ +|null default-language rows by uid + */ + private $pages; + + /** + * @var array child uids by parent uid, in sorting order + */ + private $children = []; + + /** + * @var array> overlay rows by default-language uid and language id + */ + private $overlays = []; + + public function __construct(ConnectionPool $connectionPool, Context $context) + { + $this->connectionPool = $connectionPool; + $this->now = (int) $context->getPropertyFromAspect('date', 'timestamp'); + } + + public function getPage(int $uid): ?array + { + $this->load(); + + return $this->pages[$uid] ?? null; + } + + public function getOverlay(int $uid, int $languageId): ?array + { + $this->load(); + + return $this->overlays[$uid][$languageId] ?? null; + } + + /** + * @return int[] + */ + public function getChildren(int $uid): array + { + $this->load(); + + return $this->children[$uid] ?? []; + } + + /** + * Visible to an anonymous frontend visitor: not hidden, inside its start/end window, and not + * restricted to a frontend user group. + */ + public function isVisible(array $row): bool + { + if ((int) $row['hidden'] === 1) { + return false; + } + + if ((int) $row['starttime'] > $this->now) { + return false; + } + + if ((int) $row['endtime'] !== 0 && (int) $row['endtime'] <= $this->now) { + return false; + } + + return in_array((string) $row['fe_group'], ['', '0'], true); + } + + private function load(): void + { + if (null !== $this->pages) { + return; + } + + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions()->removeAll()->add(new DeletedRestriction()); + + $rows = $queryBuilder + ->select(...self::FIELDS) + ->from('pages') + ->orderBy('sorting') + ->execute() + ->fetchAllAssociative() + ; + + $this->pages = []; + + foreach ($rows as $row) { + $uid = (int) $row['uid']; + + if ((int) $row['sys_language_uid'] > 0) { + $this->overlays[(int) $row['l10n_parent']][(int) $row['sys_language_uid']] = $row; + continue; + } + + $this->pages[$uid] = $row; + $this->children[(int) $row['pid']][] = $uid; + } + } +} diff --git a/public/typo3conf/ext/ep_products/Classes/ContentFeed/RecordFinder.php b/public/typo3conf/ext/ep_products/Classes/ContentFeed/RecordFinder.php new file mode 100644 index 00000000..a69c1f8a --- /dev/null +++ b/public/typo3conf/ext/ep_products/Classes/ContentFeed/RecordFinder.php @@ -0,0 +1,152 @@ + 'tx_epproducts_domain_model_product', + 'hotel' => 'tx_epproducts_domain_model_hotel', + 'region' => 'tx_epproducts_domain_model_region', + 'country' => 'tx_epproducts_domain_model_country', + 'city' => 'tx_epproducts_domain_model_city', + 'concept' => 'tx_epproducts_domain_model_concept', + 'news' => 'tx_news_domain_model_news', + ]; + + /** + * @var ConnectionPool + */ + private $connectionPool; + + public function __construct(ConnectionPool $connectionPool) + { + $this->connectionPool = $connectionPool; + } + + /** + * @param string[] $fields + * @param int[]|null $uids null for all eligible records + * @return array rows by uid, in uid order + */ + public function find(string $type, array $fields, ?array $uids = null): array + { + $table = self::TYPES[$type]; + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($table); + $queryBuilder + ->select(...$fields) + ->from($table) + ->where($queryBuilder->expr()->in('sys_language_uid', [0, -1])) + ->orderBy('uid') + ; + + if (null !== $uids) { + if ([] === $uids) { + return []; + } + + $queryBuilder->andWhere($queryBuilder->expr()->in( + 'uid', + $queryBuilder->createNamedParameter($uids, Connection::PARAM_INT_ARRAY) + )); + } + + $this->addTypeConstraints($type, $queryBuilder); + + $rows = []; + + foreach ($queryBuilder->execute()->fetchAllAssociative() as $row) { + $rows[(int) $row['uid']] = $row; + } + + return $rows; + } + + /** + * Latest change to a product's FAQs, by product uid. Deleted and hidden FAQs count: removing + * one is a change to the product's text. + * + * @return array + */ + public function findFaqTimestamps(): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_faq'); + $queryBuilder->getRestrictions()->removeAll(); + + $rows = $queryBuilder + ->selectLiteral('mm.uid_local AS product', 'MAX(faq.tstamp) AS tstamp') + ->from('tx_epproducts_product_faq_mm', 'mm') + ->join('mm', 'tx_epproducts_domain_model_faq', 'faq', 'faq.uid = mm.uid_foreign') + ->groupBy('mm.uid_local') + ->execute() + ->fetchAllAssociative() + ; + + return $this->indexTimestamps($rows, 'product'); + } + + /** + * Latest change to a hotel's matchcode aliases, by hotel uid. They are part of the hotel's + * `codes`, which the consumer joins its trip data on. + * + * @return array + */ + public function findMatchcodeTimestamps(): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_matchcode'); + $queryBuilder->getRestrictions()->removeAll(); + + $rows = $queryBuilder + ->selectLiteral('entity AS hotel', 'MAX(tstamp) AS tstamp') + ->from('tx_epproducts_domain_model_matchcode') + ->where($queryBuilder->expr()->eq('entity_type', $queryBuilder->createNamedParameter('hotel'))) + ->groupBy('entity') + ->execute() + ->fetchAllAssociative() + ; + + return $this->indexTimestamps($rows, 'hotel'); + } + + /** + * @return array + */ + private function indexTimestamps(array $rows, string $key): array + { + $timestamps = []; + + foreach ($rows as $row) { + $timestamps[(int) $row[$key]] = (int) $row['tstamp']; + } + + return $timestamps; + } + + private function addTypeConstraints(string $type, QueryBuilder $queryBuilder): void + { + $expr = $queryBuilder->expr(); + + if ('product' === $type) { + // A product that is neither searchable nor bookable is an internal record. + $queryBuilder->andWhere($expr->orX($expr->eq('searchable', 1), $expr->eq('bookable', 1))); + } + + if ('news' === $type) { + // Type 0 is a regular article; 1 and 2 are just links to a page or an external URL. + $queryBuilder->andWhere( + $expr->eq('type', $queryBuilder->createNamedParameter('0')), + $expr->in('fe_group', $queryBuilder->createNamedParameter(['', '0'], Connection::PARAM_STR_ARRAY)) + ); + } + } +} diff --git a/public/typo3conf/ext/ep_products/Classes/ContentFeed/UrlResolver.php b/public/typo3conf/ext/ep_products/Classes/ContentFeed/UrlResolver.php new file mode 100644 index 00000000..a52c644b --- /dev/null +++ b/public/typo3conf/ext/ep_products/Classes/ContentFeed/UrlResolver.php @@ -0,0 +1,214 @@ + news detail page uid by site identifier + */ + private $newsDetailPages; + + /** + * @var array + */ + private $sitesByPage = []; + + public function __construct(SiteFinder $siteFinder, PageTree $pageTree, ContentFeedConfiguration $configuration) + { + $this->siteFinder = $siteFinder; + $this->pageTree = $pageTree; + $this->includedSites = $configuration->getSites(); + $this->newsDetailPages = $configuration->getNewsDetailPages(); + } + + /** + * @return Site[] included sites, in configuration order + */ + public function getIncludedSites(): array + { + $sites = []; + + foreach ($this->includedSites as $identifier) { + try { + $sites[] = $this->siteFinder->getSiteByIdentifier($identifier); + } catch (SiteNotFoundException $e) { + // A misspelt identifier must not take the whole feed down; it just contributes nothing. + } + } + + return $sites; + } + + /** + * The page's site, or null when it belongs to no site or to one outside the feed. + */ + public function getIncludedSite(int $pageUid): ?Site + { + if (!array_key_exists($pageUid, $this->sitesByPage)) { + try { + $site = $this->siteFinder->getSiteByPageId($pageUid); + $this->sitesByPage[$pageUid] = in_array($site->getIdentifier(), $this->includedSites, true) ? $site : null; + } catch (SiteNotFoundException $e) { + $this->sitesByPage[$pageUid] = null; + } + } + + return $this->sitesByPage[$pageUid]; + } + + public function getPageUrl(int $pageUid, int $languageId = 0, array $parameters = []): ?string + { + $site = $this->getIncludedSite($pageUid); + + if (null === $site) { + return null; + } + + try { + $language = $site->getLanguageById($languageId); + + return (string) $site->getRouter()->generateUri($pageUid, ['_language' => $language] + $parameters); + } catch (\Throwable $e) { + // Unroutable pages (missing slug, disabled language) are left out rather than failing the feed. + return null; + } + } + + /** + * Parses a typolink page reference as stored in `detail_page`: a plain uid or `t3://page?uid=N`. + */ + public function parsePageReference(string $reference): ?int + { + $reference = trim($reference); + + if (MathUtility::canBeInterpretedAsInteger($reference)) { + return (int) $reference > 0 ? (int) $reference : null; + } + + if (1 === preg_match('/^t3:\/\/page\?(?:.*&)?uid=(\d+)/', html_entity_decode($reference), $matches)) { + return (int) $matches[1]; + } + + return null; + } + + /** + * Follows shortcut pages the way a typolink does, and returns the uid of the visible standard + * page a link to $pageUid ends up on - or null when the chain breaks or loops. + */ + public function resolveLinkTarget(int $pageUid): ?int + { + for ($depth = 0; $depth < self::MAX_SHORTCUT_DEPTH; $depth++) { + $page = $this->pageTree->getPage($pageUid); + + if (null === $page || !$this->pageTree->isVisible($page)) { + return null; + } + + if ((int) $page['doktype'] !== self::DOKTYPE_SHORTCUT) { + return (int) $page['doktype'] === self::DOKTYPE_DEFAULT ? $pageUid : null; + } + + $next = $this->getShortcutTarget($page); + + if (null === $next) { + return null; + } + + $pageUid = $next; + } + + return null; + } + + /** + * @return array{url: ?string, site: ?string} + */ + public function getRecordLink(string $detailPage): array + { + $pageUid = $this->parsePageReference($detailPage); + $target = null === $pageUid ? null : $this->resolveLinkTarget($pageUid); + $site = null === $target ? null : $this->getIncludedSite($target); + $url = null === $site ? null : $this->getPageUrl($target); + + return [ + 'url' => $url, + 'site' => null === $url ? null : $site->getIdentifier(), + ]; + } + + /** + * News has no detail_page of its own: the storage folder decides the site, and the site's + * configured detail page carries the article through the NewsDetailPlugin route enhancer. + * + * @return array{url: ?string, site: string}|null null when the article is stored outside the feed's sites + */ + public function getNewsLink(int $newsUid, int $storagePid): ?array + { + $site = $this->getIncludedSite($storagePid); + + if (null === $site) { + return null; + } + + $detailPage = $this->newsDetailPages[$site->getIdentifier()] ?? null; + $url = null === $detailPage ? null : $this->getPageUrl($detailPage, 0, [ + 'tx_news_pi1' => ['action' => 'detail', 'controller' => 'News', 'news' => $newsUid], + ]); + + return ['url' => $url, 'site' => $site->getIdentifier()]; + } + + private function getShortcutTarget(array $page): ?int + { + switch ((int) $page['shortcut_mode']) { + case self::SHORTCUT_MODE_NONE: + return (int) $page['shortcut'] > 0 ? (int) $page['shortcut'] : null; + case self::SHORTCUT_MODE_FIRST_SUBPAGE: + foreach ($this->pageTree->getChildren((int) $page['uid']) as $childUid) { + if ($this->pageTree->isVisible($this->pageTree->getPage($childUid))) { + return $childUid; + } + } + + return null; + case self::SHORTCUT_MODE_PARENT_PAGE: + return (int) $page['pid'] > 0 ? (int) $page['pid'] : null; + default: + // Random subpage: there is no stable URL to give. + return null; + } + } +} diff --git a/public/typo3conf/ext/ep_products/Classes/Middleware/ApiMiddleware.php b/public/typo3conf/ext/ep_products/Classes/Middleware/ApiMiddleware.php index aa109a47..c9920584 100644 --- a/public/typo3conf/ext/ep_products/Classes/Middleware/ApiMiddleware.php +++ b/public/typo3conf/ext/ep_products/Classes/Middleware/ApiMiddleware.php @@ -2,6 +2,7 @@ namespace EP\EpProducts\Middleware; +use EP\EpProducts\ContentFeed\ContentFeedApi; use EP\EpProducts\Service\MyEpImageService; use EP\EpProducts\Utility\HotelIconUtility; use Psr\Http\Message\ResponseInterface; @@ -41,18 +42,25 @@ class ApiMiddleware implements MiddlewareInterface */ private $imageService; + /** + * @var ContentFeedApi + */ + private $contentFeedApi; + public function __construct( MyEpImageService $hotelImageService, MyEpImageService $productImageService, MyEpImageService $regionImageService, FileRepository $fileRepository, - ImageService $imageService + ImageService $imageService, + ContentFeedApi $contentFeedApi ) { $this->hotelImageService = $hotelImageService; $this->productImageService = $productImageService; $this->regionImageService = $regionImageService; $this->fileRepository = $fileRepository; $this->imageService = $imageService; + $this->contentFeedApi = $contentFeedApi; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -67,6 +75,10 @@ class ApiMiddleware implements MiddlewareInterface return $this->handleProductRequest($request); } + if (0 === strpos($path, ContentFeedApi::PATH_PREFIX)) { + return $this->contentFeedApi->handle($request); + } + return $handler->handle($request); } diff --git a/public/typo3conf/ext/ep_products/ext_conf_template.txt b/public/typo3conf/ext/ep_products/ext_conf_template.txt index 428a16a2..9976c147 100644 --- a/public/typo3conf/ext/ep_products/ext_conf_template.txt +++ b/public/typo3conf/ext/ep_products/ext_conf_template.txt @@ -85,6 +85,9 @@ contentFeedEnabled = 1 # cat=ContentFeed; type=string; label=Site identifiers included in the chatbot content feed (comma-separated) contentFeedSites = ep-reisen,ser,ski-boarderweek,ski-wochenenden,snowzone +# cat=ContentFeed; type=string; label=Language ids included in the chatbot content feed (comma-separated; the English pages are untranslated placeholders today) +contentFeedLanguages = 0 + # cat=ContentFeed; type=string; label=Page uids whose subtrees stay out of the chatbot content feed (comma-separated; 634 = group booking links) contentFeedExcludedPages = 634