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