364852f28f58c2787a8e1b530c29a5ca8ac7d918
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / DataHandling / SlugHelper.php
1 <?php
2 declare(strict_types = 1);
3 namespace TYPO3\CMS\Core\DataHandling;
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
18 use Doctrine\DBAL\Connection;
19 use TYPO3\CMS\Backend\Utility\BackendUtility;
20 use TYPO3\CMS\Core\Charset\CharsetConverter;
21 use TYPO3\CMS\Core\Database\ConnectionPool;
22 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
23 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
24 use TYPO3\CMS\Core\DataHandling\Model\RecordState;
25 use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
26 use TYPO3\CMS\Core\Exception\SiteNotFoundException;
27 use TYPO3\CMS\Core\Routing\SiteMatcher;
28 use TYPO3\CMS\Core\Utility\GeneralUtility;
29 use TYPO3\CMS\Core\Utility\MathUtility;
30 use TYPO3\CMS\Core\Versioning\VersionState;
31
32 /**
33 * Generates, sanitizes and validates slugs for a TCA field
34 */
35 class SlugHelper
36 {
37 /**
38 * @var string
39 */
40 protected $tableName;
41
42 /**
43 * @var string
44 */
45 protected $fieldName;
46
47 /**
48 * @var int
49 */
50 protected $workspaceId;
51
52 /**
53 * @var array
54 */
55 protected $configuration = [];
56
57 /**
58 * @var bool
59 */
60 protected $workspaceEnabled;
61
62 /**
63 * Defines whether the slug field should start with "/".
64 * For pages (due to rootline functionality), this is a must have, otherwise the root level page
65 * would have an empty value.
66 *
67 * @var bool
68 */
69 protected $prependSlashInSlug;
70
71 /**
72 * Slug constructor.
73 *
74 * @param string $tableName TCA table
75 * @param string $fieldName TCA field
76 * @param array $configuration TCA configuration of the field
77 * @param int $workspaceId the workspace ID to be working on.
78 */
79 public function __construct(string $tableName, string $fieldName, array $configuration, int $workspaceId = 0)
80 {
81 $this->tableName = $tableName;
82 $this->fieldName = $fieldName;
83 $this->configuration = $configuration;
84 $this->workspaceId = $workspaceId;
85
86 if ($this->tableName === 'pages' && $this->fieldName === 'slug') {
87 $this->prependSlashInSlug = true;
88 } else {
89 $this->prependSlashInSlug = $this->configuration['prependSlash'] ?? false;
90 }
91
92 $this->workspaceEnabled = BackendUtility::isTableWorkspaceEnabled($tableName);
93 }
94
95 /**
96 * Cleans a slug value so it is used directly in the path segment of a URL.
97 *
98 * @param string $slug
99 * @return string
100 */
101 public function sanitize(string $slug): string
102 {
103 // Convert to lowercase + remove tags
104 $slug = mb_strtolower($slug, 'utf-8');
105 $slug = strip_tags($slug);
106
107 // Convert some special tokens (space, "_" and "-") to the space character
108 $fallbackCharacter = (string)($this->configuration['fallbackCharacter'] ?? '-');
109 $slug = preg_replace('/[ \t\x{00A0}\-+_]+/u', $fallbackCharacter, $slug);
110
111 // Convert extended letters to ascii equivalents
112 // The specCharsToASCII() converts "€" to "EUR"
113 $slug = GeneralUtility::makeInstance(CharsetConverter::class)->specCharsToASCII('utf-8', $slug);
114
115 // Get rid of all invalid characters, but allow slashes
116 $slug = preg_replace('/[^\p{L}0-9\/' . preg_quote($fallbackCharacter) . ']/u', '', $slug);
117
118 // Convert multiple fallback characters to a single one
119 if ($fallbackCharacter !== '') {
120 $slug = preg_replace('/' . preg_quote($fallbackCharacter) . '{2,}/', $fallbackCharacter, $slug);
121 }
122
123 // Ensure slug is lower cased after all replacement was done
124 $slug = mb_strtolower($slug, 'utf-8');
125 // keep slashes: re-convert them after rawurlencode did everything
126 $slug = rawurlencode($slug);
127 // @todo: add a test and see if we need this
128 $slug = str_replace('%2F', '/', $slug);
129 // Extract slug, thus it does not have wrapping fallback and slash characters
130 $extractedSlug = $this->extract($slug);
131 // Remove trailing and beginning slashes, except if the trailing slash was added, then we'll re-add it
132 $appendTrailingSlash = $extractedSlug !== '' && substr($slug, -1) === '/';
133 $slug = $extractedSlug . ($appendTrailingSlash ? '/' : '');
134 if ($this->prependSlashInSlug && ($slug{0} ?? '') !== '/') {
135 $slug = '/' . $slug;
136 }
137 return $slug;
138 }
139
140 /**
141 * Extracts payload of slug and removes wrapping delimiters,
142 * e.g. `/hello/world/` will become `hello/world`.
143 *
144 * @param string $slug
145 * @return string
146 */
147 public function extract(string $slug): string
148 {
149 // Convert some special tokens (space, "_" and "-") to the space character
150 $fallbackCharacter = $this->configuration['fallbackCharacter'] ?? '-';
151 return trim($slug, $fallbackCharacter . '/');
152 }
153
154 /**
155 * Used when no slug exists for a record
156 *
157 * @param array $recordData
158 * @param int $pid
159 * @return string
160 */
161 public function generate(array $recordData, int $pid): string
162 {
163 if ($pid === 0 || (!empty($recordData['is_siteroot']) && $this->tableName === 'pages')) {
164 return '/';
165 }
166 $prefix = '';
167 if ($this->configuration['generatorOptions']['prefixParentPageSlug'] ?? false) {
168 $languageFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
169 $languageId = (int)($recordData[$languageFieldName] ?? 0);
170 $parentPageRecord = $this->resolveParentPageRecord($pid, $languageId);
171 if (is_array($parentPageRecord)) {
172 // If the parent page has a slug, use that instead of "re-generating" the slug from the parents' page title
173 if (!empty($parentPageRecord['slug'])) {
174 $rootLineItemSlug = $parentPageRecord['slug'];
175 } else {
176 $rootLineItemSlug = $this->generate($parentPageRecord, (int)$parentPageRecord['pid']);
177 }
178 $rootLineItemSlug = trim($rootLineItemSlug, '/');
179 if (!empty($rootLineItemSlug)) {
180 $prefix = $rootLineItemSlug;
181 }
182 }
183 }
184
185 $fieldSeparator = $this->configuration['generatorOptions']['fieldSeparator'] ?? '/';
186 $slugParts = [];
187 foreach ($this->configuration['generatorOptions']['fields'] ?? [] as $fieldName) {
188 if (!empty($recordData[$fieldName])) {
189 $slugParts[] = $recordData[$fieldName];
190 }
191 }
192 $slug = implode($fieldSeparator, $slugParts);
193 $slug = $this->sanitize($slug);
194 // No valid data found
195 if ($slug === '' || $slug === '/') {
196 $slug = 'default-' . GeneralUtility::shortMD5(json_encode($recordData));
197 }
198 if ($this->prependSlashInSlug && ($slug{0} ?? '') !== '/') {
199 $slug = '/' . $slug;
200 }
201 if (!empty($prefix)) {
202 $slug = $prefix . $slug;
203 }
204
205 return $this->sanitize($slug);
206 }
207
208 /**
209 * Checks if there are other records with the same slug that are located on the same PID.
210 *
211 * @param string $slug
212 * @param RecordState $state
213 * @return bool
214 */
215 public function isUniqueInPid(string $slug, RecordState $state): bool
216 {
217 $pageId = (int)$state->resolveNodeIdentifier();
218 $recordId = $state->getSubject()->getIdentifier();
219 $languageId = $state->getContext()->getLanguageId();
220
221 if ($pageId < 0) {
222 $pageId = $this->resolveLivePageId($recordId);
223 }
224
225 $queryBuilder = $this->createPreparedQueryBuilder();
226 $this->applySlugConstraint($queryBuilder, $slug);
227 $this->applyPageIdConstraint($queryBuilder, $pageId);
228 $this->applyRecordConstraint($queryBuilder, $recordId);
229 $this->applyLanguageConstraint($queryBuilder, $languageId);
230 $this->applyWorkspaceConstraint($queryBuilder);
231 $statement = $queryBuilder->execute();
232
233 $records = $this->resolveVersionOverlays(
234 $statement->fetchAll()
235 );
236 return count($records) === 0;
237 }
238
239 /**
240 * Check if there are other records with the same slug that are located on the same site.
241 *
242 * @param string $slug
243 * @param RecordState $state
244 * @return bool
245 * @throws \TYPO3\CMS\Core\Exception\SiteNotFoundException
246 */
247 public function isUniqueInSite(string $slug, RecordState $state): bool
248 {
249 $pageId = (int)$state->resolveNodeIdentifier();
250 $recordId = $state->getSubject()->getIdentifier();
251 $languageId = $state->getContext()->getLanguageId();
252
253 if ($pageId < 0) {
254 $pageId = $this->resolveLivePageId($recordId);
255 }
256
257 $queryBuilder = $this->createPreparedQueryBuilder();
258 $this->applySlugConstraint($queryBuilder, $slug);
259 $this->applyRecordConstraint($queryBuilder, $recordId);
260 $this->applyLanguageConstraint($queryBuilder, $languageId);
261 $this->applyWorkspaceConstraint($queryBuilder);
262 $statement = $queryBuilder->execute();
263
264 $records = $this->resolveVersionOverlays(
265 $statement->fetchAll()
266 );
267 if (count($records) === 0) {
268 return true;
269 }
270
271 // The installation contains at least ONE other record with the same slug
272 // Now find out if it is the same root page ID
273 $siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
274 $siteOfCurrentRecord = $siteMatcher->matchByPageId($pageId);
275 foreach ($records as $record) {
276 try {
277 $recordState = RecordStateFactory::forName($this->tableName)->fromArray($record);
278 $siteOfExistingRecord = $siteMatcher->matchByPageId(
279 (int)$recordState->resolveNodeAggregateIdentifier()
280 );
281 } catch (SiteNotFoundException $exception) {
282 // In case not site is found, the record is not
283 // organized in any site or pseudo-site
284 continue;
285 }
286 if ($siteOfExistingRecord->getRootPageId() === $siteOfCurrentRecord->getRootPageId()) {
287 return false;
288 }
289 }
290
291 // Otherwise, everything is still fine
292 return true;
293 }
294
295 /**
296 * Generate a slug with a suffix "/mytitle-1" if that is in use already.
297 *
298 * @param string $slug proposed slug
299 * @param RecordState $state
300 * @return string
301 * @throws \TYPO3\CMS\Core\Exception\SiteNotFoundException
302 */
303 public function buildSlugForUniqueInSite(string $slug, RecordState $state): string
304 {
305 $slug = $this->sanitize($slug);
306 $rawValue = $this->extract($slug);
307 $newValue = $slug;
308 $counter = 0;
309 while (!$this->isUniqueInSite(
310 $newValue,
311 $state
312 ) && $counter++ < 100
313 ) {
314 $newValue = $this->sanitize($rawValue . '-' . $counter);
315 }
316 if ($counter === 100) {
317 $newValue = $this->sanitize($rawValue . '-' . GeneralUtility::shortMD5($rawValue));
318 }
319 return $newValue;
320 }
321
322 /**
323 * Generate a slug with a suffix "/mytitle-1" if the suggested slug is in use already.
324 *
325 * @param string $slug proposed slug
326 * @param RecordState $state
327 * @return string
328 */
329 public function buildSlugForUniqueInPid(string $slug, RecordState $state): string
330 {
331 $slug = $this->sanitize($slug);
332 $rawValue = $this->extract($slug);
333 $newValue = $slug;
334 $counter = 0;
335 while (!$this->isUniqueInPid(
336 $newValue,
337 $state
338 ) && $counter++ < 100
339 ) {
340 $newValue = $this->sanitize($rawValue . '-' . $counter);
341 }
342 if ($counter === 100) {
343 $newValue = $this->sanitize($rawValue . '-' . GeneralUtility::shortMD5($rawValue));
344 }
345 return $newValue;
346 }
347
348 /**
349 * @return QueryBuilder
350 */
351 protected function createPreparedQueryBuilder(): QueryBuilder
352 {
353 $fieldNames = ['uid', 'pid', $this->fieldName];
354 if ($this->workspaceEnabled) {
355 $fieldNames[] = 't3ver_state';
356 }
357 $languageFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
358 if (is_string($languageFieldName)) {
359 $fieldNames[] = $languageFieldName;
360 }
361 $languageParentFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['transOrigPointerField'] ?? null;
362 if (is_string($languageParentFieldName)) {
363 $fieldNames[] = $languageParentFieldName;
364 }
365
366 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->tableName);
367 $queryBuilder->getRestrictions()
368 ->removeAll()
369 ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
370 $queryBuilder
371 ->select(...$fieldNames)
372 ->from($this->tableName);
373 return $queryBuilder;
374 }
375
376 /**
377 * @param QueryBuilder $queryBuilder
378 */
379 protected function applyWorkspaceConstraint(QueryBuilder $queryBuilder)
380 {
381 if (!$this->workspaceEnabled) {
382 return;
383 }
384
385 $workspaceIds = [0];
386 if ($this->workspaceId > 0) {
387 $workspaceIds[] = $this->workspaceId;
388 }
389 $queryBuilder->andWhere(
390 $queryBuilder->expr()->in(
391 't3ver_wsid',
392 $queryBuilder->createNamedParameter($workspaceIds, Connection::PARAM_INT_ARRAY)
393 ),
394 $queryBuilder->expr()->neq(
395 'pid',
396 $queryBuilder->createNamedParameter(-1, \PDO::PARAM_INT)
397 )
398 );
399 }
400
401 /**
402 * @param QueryBuilder $queryBuilder
403 * @param int $languageId
404 */
405 protected function applyLanguageConstraint(QueryBuilder $queryBuilder, int $languageId)
406 {
407 $languageFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
408 if (!is_string($languageFieldName)) {
409 return;
410 }
411
412 // Only check records of the given language
413 $queryBuilder->andWhere(
414 $queryBuilder->expr()->eq(
415 $languageFieldName,
416 $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
417 )
418 );
419 }
420
421 /**
422 * @param QueryBuilder $queryBuilder
423 * @param string $slug
424 */
425 protected function applySlugConstraint(QueryBuilder $queryBuilder, string $slug)
426 {
427 $queryBuilder->where(
428 $queryBuilder->expr()->eq(
429 $this->fieldName,
430 $queryBuilder->createNamedParameter($slug)
431 )
432 );
433 }
434
435 /**
436 * @param QueryBuilder $queryBuilder
437 * @param int $pageId
438 */
439 protected function applyPageIdConstraint(QueryBuilder $queryBuilder, int $pageId)
440 {
441 if ($pageId < 0) {
442 throw new \RuntimeException(
443 sprintf(
444 'Page id must be positive "%d"',
445 $pageId
446 ),
447 1534962573
448 );
449 }
450
451 $queryBuilder->andWhere(
452 $queryBuilder->expr()->eq(
453 'pid',
454 $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
455 )
456 );
457 }
458
459 /**
460 * @param QueryBuilder $queryBuilder
461 * @param string|int $recordId
462 */
463 protected function applyRecordConstraint(QueryBuilder $queryBuilder, $recordId)
464 {
465 // Exclude the current record if it is an existing record
466 if (!MathUtility::canBeInterpretedAsInteger($recordId)) {
467 return;
468 }
469
470 $queryBuilder->andWhere(
471 $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($recordId, \PDO::PARAM_INT))
472 );
473 if ($this->workspaceId > 0 && $this->workspaceEnabled) {
474 $liveId = BackendUtility::getLiveVersionIdOfRecord($this->tableName, $recordId) ?? $recordId;
475 $queryBuilder->andWhere(
476 $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($liveId, \PDO::PARAM_INT))
477 );
478 }
479 }
480
481 /**
482 * @param int $recordId
483 * @return int
484 * @throws \RuntimeException
485 */
486 protected function resolveLivePageId($recordId): int
487 {
488 if (!MathUtility::canBeInterpretedAsInteger($recordId)) {
489 throw new \RuntimeException(
490 sprintf(
491 'Cannot resolve live page id for non-numeric identifier "%s"',
492 $recordId
493 ),
494 1534951024
495 );
496 }
497
498 $liveVersion = BackendUtility::getLiveVersionOfRecord(
499 $this->tableName,
500 $recordId,
501 'pid'
502 );
503
504 if (empty($liveVersion)) {
505 throw new \RuntimeException(
506 sprintf(
507 'Cannot resolve live page id for record "%s:%d"',
508 $this->tableName,
509 $recordId
510 ),
511 1534951025
512 );
513 }
514
515 return (int)$liveVersion['pid'];
516 }
517
518 /**
519 * @param array $records
520 * @return array
521 */
522 protected function resolveVersionOverlays(array $records): array
523 {
524 if (!$this->workspaceEnabled) {
525 return $records;
526 }
527
528 return array_filter(
529 array_map(
530 function (array $record) {
531 BackendUtility::workspaceOL(
532 $this->tableName,
533 $record,
534 $this->workspaceId,
535 true
536 );
537 if (VersionState::cast($record['t3ver_state'] ?? null)
538 ->equals(VersionState::DELETE_PLACEHOLDER)) {
539 return null;
540 }
541 return $record;
542 },
543 $records
544 )
545 );
546 }
547
548 /**
549 * Fetch a parent page, but exclude spacers, recyclers and sys-folders and all doktypes > 200
550 * @param int $pid
551 * @param int $languageId
552 * @return array|null
553 */
554 protected function resolveParentPageRecord(int $pid, int $languageId): ?array
555 {
556 $parentPageRecord = null;
557 $rootLine = BackendUtility::BEgetRootLine($pid, '', true, ['nav_title']);
558 do {
559 $parentPageRecord = array_shift($rootLine);
560 // do not use spacers (199), recyclers and folders and everything else
561 } while (!empty($rootLine) && (int)$parentPageRecord['doktype'] >= 199);
562 if ($languageId > 0) {
563 $localizedParentPageRecord = BackendUtility::getRecordLocalization('pages', $parentPageRecord['uid'], $languageId);
564 if (!empty($localizedParentPageRecord)) {
565 $parentPageRecord = reset($localizedParentPageRecord);
566 }
567 }
568 return $parentPageRecord;
569 }
570 }