1<?php
2
3declare(strict_types=1);
4
5/*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18namespace TYPO3\CMS\Redirects\Service;
19
20use Doctrine\DBAL\Connection;
21use Psr\Log\LoggerAwareInterface;
22use Psr\Log\LoggerAwareTrait;
23use TYPO3\CMS\Backend\Utility\BackendUtility;
24use TYPO3\CMS\Core\Context\Context;
25use TYPO3\CMS\Core\Context\DateTimeAspect;
26use TYPO3\CMS\Core\Database\ConnectionPool;
27use TYPO3\CMS\Core\Database\Query\QueryBuilder;
28use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
29use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
30use TYPO3\CMS\Core\DataHandling\DataHandler;
31use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
32use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
33use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
34use TYPO3\CMS\Core\DataHandling\SlugHelper;
35use TYPO3\CMS\Core\Domain\Repository\PageRepository;
36use TYPO3\CMS\Core\LinkHandling\LinkService;
37use TYPO3\CMS\Core\Localization\LanguageService;
38use TYPO3\CMS\Core\Page\PageRenderer;
39use TYPO3\CMS\Core\Site\Entity\SiteInterface;
40use TYPO3\CMS\Core\Site\SiteFinder;
41use TYPO3\CMS\Core\Utility\GeneralUtility;
42use TYPO3\CMS\Redirects\Hooks\DataHandlerSlugUpdateHook;
43
44/**
45 * @internal Due to some possible refactorings in TYPO3 v10
46 */
47class SlugService implements LoggerAwareInterface
48{
49    use LoggerAwareTrait;
50
51    /**
52     * `dechex(1569615472)` (similar to timestamps used with exceptions, but in hex)
53     */
54    public const CORRELATION_ID_IDENTIFIER = '5d8e6e70';
55
56    /**
57     * @var Context
58     */
59    protected $context;
60
61    /**
62     * @var LanguageService
63     */
64    protected $languageService;
65
66    /**
67     * @var SiteInterface
68     */
69    protected $site;
70
71    /**
72     * @var SiteFinder
73     */
74    protected $siteFinder;
75
76    /**
77     * @var PageRepository
78     */
79    protected $pageRepository;
80
81    /**
82     * @var LinkService
83     */
84    protected $linkService;
85
86    /**
87     * @var CorrelationId|string
88     */
89    protected $correlationIdRedirectCreation = '';
90
91    /**
92     * @var CorrelationId|string
93     */
94    protected $correlationIdSlugUpdate = '';
95
96    /**
97     * @var bool
98     */
99    protected $autoUpdateSlugs;
100
101    /**
102     * @var bool
103     */
104    protected $autoCreateRedirects;
105
106    /**
107     * @var int
108     */
109    protected $redirectTTL;
110
111    /**
112     * @var int
113     */
114    protected $httpStatusCode;
115
116    public function __construct(Context $context, LanguageService $languageService, SiteFinder $siteFinder, PageRepository $pageRepository, LinkService $linkService)
117    {
118        $this->context = $context;
119        $this->languageService = $languageService;
120        $this->siteFinder = $siteFinder;
121        $this->pageRepository = $pageRepository;
122        $this->linkService = $linkService;
123    }
124
125    public function rebuildSlugsForSlugChange(int $pageId, string $currentSlug, string $newSlug, CorrelationId $correlationId): void
126    {
127        $currentPageRecord = BackendUtility::getRecord('pages', $pageId);
128        if ($currentPageRecord === null) {
129            return;
130        }
131        $defaultPageId = (int)$currentPageRecord['sys_language_uid'] > 0 ? (int)$currentPageRecord['l10n_parent'] : $pageId;
132        $this->initializeSettings($defaultPageId);
133        if ($this->autoUpdateSlugs || $this->autoCreateRedirects) {
134            $this->createCorrelationIds($pageId, $correlationId);
135            if ($this->autoCreateRedirects) {
136                $this->createRedirect($currentSlug, $defaultPageId, (int)$currentPageRecord['sys_language_uid'], (int)$pageId);
137            }
138            if ($this->autoUpdateSlugs) {
139                $this->checkSubPages($currentPageRecord, $currentSlug, $newSlug);
140            }
141            $this->sendNotification();
142            GeneralUtility::makeInstance(RedirectCacheService::class)->rebuild();
143        }
144    }
145
146    protected function initializeSettings(int $pageId): void
147    {
148        $this->site = $this->siteFinder->getSiteByPageId($pageId);
149        $settings = $this->site->getConfiguration()['settings']['redirects'] ?? [];
150        $this->autoUpdateSlugs = $settings['autoUpdateSlugs'] ?? true;
151        $this->autoCreateRedirects = $settings['autoCreateRedirects'] ?? true;
152        if (!$this->context->getPropertyFromAspect('workspace', 'isLive')) {
153            $this->autoCreateRedirects = false;
154        }
155        $this->redirectTTL = (int)($settings['redirectTTL'] ?? 0);
156        $this->httpStatusCode = (int)($settings['httpStatusCode'] ?? 307);
157    }
158
159    protected function createCorrelationIds(int $pageId, CorrelationId $correlationId): void
160    {
161        if ($correlationId->getSubject() === null) {
162            $subject = md5('pages:' . $pageId);
163            $correlationId = $correlationId->withSubject($subject);
164        }
165
166        $this->correlationIdRedirectCreation = $correlationId->withAspects(self::CORRELATION_ID_IDENTIFIER, 'redirect');
167        $this->correlationIdSlugUpdate = $correlationId->withAspects(self::CORRELATION_ID_IDENTIFIER, 'slug');
168    }
169
170    protected function createRedirect(string $originalSlug, int $pageId, int $languageId, int $pid): void
171    {
172        $siteLanguage = $this->site->getLanguageById($languageId);
173        $basePath = rtrim($siteLanguage->getBase()->getPath(), '/');
174
175        /** @var DateTimeAspect $date */
176        $date = $this->context->getAspect('date');
177        $endtime = $date->getDateTime()->modify('+' . $this->redirectTTL . ' days');
178        $targetLink = $this->linkService->asString([
179            'type' => 'page',
180            'pageuid' => $pageId,
181            'parameters' => '_language=' . $languageId
182        ]);
183        $record = [
184            'pid' => 0,
185            'updatedon' => $date->get('timestamp'),
186            'createdon' => $date->get('timestamp'),
187            'createdby' => $this->context->getPropertyFromAspect('backend.user', 'id'),
188            'deleted' => 0,
189            'disabled' => 0,
190            'starttime' => 0,
191            'endtime' => $this->redirectTTL > 0 ? $endtime->getTimestamp() : 0,
192            'source_host' => $siteLanguage->getBase()->getHost() ?: '*',
193            'source_path' => $basePath . $originalSlug,
194            'is_regexp' => 0,
195            'force_https' => 0,
196            'respect_query_parameters' => 0,
197            'target' => $targetLink,
198            'target_statuscode' => $this->httpStatusCode,
199            'hitcount' => 0,
200            'lasthiton' => 0,
201            'disable_hitcount' => 0,
202        ];
203        $connection = GeneralUtility::makeInstance(ConnectionPool::class)
204            ->getConnectionForTable('sys_redirect');
205        $connection->insert('sys_redirect', $record);
206        $id = (int)$connection->lastInsertId('sys_redirect');
207        $record['uid'] = $id;
208        $this->getRecordHistoryStore()->addRecord('sys_redirect', $id, $record, $this->correlationIdRedirectCreation);
209    }
210
211    protected function checkSubPages(array $currentPageRecord, string $oldSlugOfParentPage, string $newSlugOfParentPage): void
212    {
213        $languageUid = (int)$currentPageRecord['sys_language_uid'];
214        // resolveSubPages needs the page id of the default language
215        $pageId = $languageUid === 0 ? (int)$currentPageRecord['uid'] : (int)$currentPageRecord['l10n_parent'];
216        $subPageRecords = $this->resolveSubPages($pageId, $languageUid);
217        foreach ($subPageRecords as $subPageRecord) {
218            $newSlug = $this->updateSlug($subPageRecord, $oldSlugOfParentPage, $newSlugOfParentPage);
219            if ($newSlug !== null && $this->autoCreateRedirects) {
220                $subPageId = (int)$subPageRecord['sys_language_uid'] === 0 ? (int)$subPageRecord['uid'] : (int)$subPageRecord['l10n_parent'];
221                $this->createRedirect($subPageRecord['slug'], $subPageId, $languageUid, $pageId);
222            }
223        }
224    }
225
226    protected function resolveSubPages(int $id, int $languageUid): array
227    {
228        // First resolve all sub-pages in default language
229        $queryBuilder = $this->getQueryBuilderForPages();
230        $subPages = $queryBuilder
231            ->select('*')
232            ->from('pages')
233            ->where(
234                $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)),
235                $queryBuilder->expr()->eq('sys_language_uid', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
236            )
237            ->orderBy('uid', 'ASC')
238            ->execute()
239            ->fetchAll();
240
241        // if the language is not the default language, resolve the language related records.
242        if ($languageUid > 0) {
243            $queryBuilder = $this->getQueryBuilderForPages();
244            $subPages = $queryBuilder
245                ->select('*')
246                ->from('pages')
247                ->where(
248                    $queryBuilder->expr()->in('l10n_parent', $queryBuilder->createNamedParameter(array_column($subPages, 'uid'), Connection::PARAM_INT_ARRAY)),
249                    $queryBuilder->expr()->eq('sys_language_uid', $queryBuilder->createNamedParameter($languageUid, \PDO::PARAM_INT))
250                )
251                ->orderBy('uid', 'ASC')
252                ->execute()
253                ->fetchAll();
254        }
255        $results = [];
256        if (!empty($subPages)) {
257            $subPages = $this->pageRepository->getPagesOverlay($subPages, $languageUid);
258            foreach ($subPages as $subPage) {
259                $results[] = $subPage;
260                // resolveSubPages needs the page id of the default language
261                $pageId = $languageUid === 0 ? (int)$subPage['uid'] : (int)$subPage['l10n_parent'];
262                foreach ($this->resolveSubPages($pageId, $languageUid) as $page) {
263                    $results[] = $page;
264                }
265            }
266        }
267        return $results;
268    }
269
270    /**
271     * Update a slug by given record, old parent page slug and new parent page slug.
272     * In case no update is required, the method returns null else the new slug.
273     *
274     * @param array $subPageRecord
275     * @param string $oldSlugOfParentPage
276     * @param string $newSlugOfParentPage
277     * @return string|null
278     */
279    protected function updateSlug(array $subPageRecord, string $oldSlugOfParentPage, string $newSlugOfParentPage): ?string
280    {
281        if (strpos($subPageRecord['slug'], $oldSlugOfParentPage) !== 0) {
282            return null;
283        }
284
285        $newSlug = rtrim($newSlugOfParentPage, '/') . '/'
286            . substr($subPageRecord['slug'], strlen(rtrim($oldSlugOfParentPage, '/') . '/'));
287        $state = RecordStateFactory::forName('pages')
288            ->fromArray($subPageRecord, $subPageRecord['pid'], $subPageRecord['uid']);
289        $fieldConfig = $GLOBALS['TCA']['pages']['columns']['slug']['config'] ?? [];
290        $slugHelper = GeneralUtility::makeInstance(SlugHelper::class, 'pages', 'slug', $fieldConfig);
291
292        if (!$slugHelper->isUniqueInSite($newSlug, $state)) {
293            $newSlug = $slugHelper->buildSlugForUniqueInSite($newSlug, $state);
294        }
295
296        $this->persistNewSlug((int)$subPageRecord['uid'], $newSlug);
297        return $newSlug;
298    }
299
300    /**
301     * @param int $uid
302     * @param string $newSlug
303     */
304    protected function persistNewSlug(int $uid, string $newSlug): void
305    {
306        $this->disableHook();
307        $data = [];
308        $data['pages'][$uid]['slug'] = $newSlug;
309        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
310        $dataHandler->start($data, []);
311        $dataHandler->setCorrelationId($this->correlationIdSlugUpdate);
312        $dataHandler->process_datamap();
313        $this->enabledHook();
314    }
315
316    protected function sendNotification(): void
317    {
318        $data = [
319            'componentName' => 'redirects',
320            'eventName' => 'slugChanged',
321            'correlations' => [
322                'correlationIdSlugUpdate' => $this->correlationIdSlugUpdate,
323                'correlationIdRedirectCreation' => $this->correlationIdRedirectCreation,
324            ],
325            'autoUpdateSlugs' => (bool)$this->autoUpdateSlugs,
326            'autoCreateRedirects' => (bool)$this->autoCreateRedirects,
327        ];
328        GeneralUtility::makeInstance(PageRenderer::class)->loadRequireJsModule(
329            'TYPO3/CMS/Backend/BroadcastService',
330            sprintf('function(service) { service.post(%s); }', json_encode($data))
331        );
332    }
333
334    protected function getRecordHistoryStore(): RecordHistoryStore
335    {
336        $backendUser = $GLOBALS['BE_USER'];
337        return GeneralUtility::makeInstance(
338            RecordHistoryStore::class,
339            RecordHistoryStore::USER_BACKEND,
340            $backendUser->user['uid'],
341            $backendUser->user['ses_backuserid'] ?? null,
342            $this->context->getPropertyFromAspect('date', 'timestamp'),
343            $backendUser->workspace ?? 0
344        );
345    }
346
347    protected function getQueryBuilderForPages(): QueryBuilder
348    {
349        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
350            ->getQueryBuilderForTable('pages');
351        /** @noinspection PhpStrictTypeCheckingInspection */
352        $queryBuilder
353            ->getRestrictions()
354            ->removeAll()
355            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
356            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->context->getPropertyFromAspect('workspace', 'id')));
357        return $queryBuilder;
358    }
359
360    protected function enabledHook(): void
361    {
362        $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects'] =
363            DataHandlerSlugUpdateHook::class;
364    }
365
366    protected function disableHook(): void
367    {
368        unset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass']['redirects']);
369    }
370}
371