[BUGFIX] Avoid suffixing base slug again
[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\Routing\SiteMatcher;
25 use TYPO3\CMS\Core\Utility\GeneralUtility;
26 use TYPO3\CMS\Core\Utility\MathUtility;
27
28 /**
29 * Generates, sanitizes and validates slugs for a TCA field
30 */
31 class SlugHelper
32 {
33 /**
34 * @var string
35 */
36 protected $tableName;
37
38 /**
39 * @var string
40 */
41 protected $fieldName;
42
43 /**
44 * @var int
45 */
46 protected $workspaceId;
47
48 /**
49 * @var array
50 */
51 protected $configuration = [];
52
53 /**
54 * @var bool
55 */
56 protected $workspaceEnabled;
57
58 /**
59 * Slug constructor.
60 *
61 * @param string $tableName TCA table
62 * @param string $fieldName TCA field
63 * @param array $configuration TCA configuration of the field
64 * @param int $workspaceId the workspace ID to be working on.
65 */
66 public function __construct(string $tableName, string $fieldName, array $configuration, int $workspaceId = 0)
67 {
68 $this->tableName = $tableName;
69 $this->fieldName = $fieldName;
70 $this->configuration = $configuration;
71 $this->workspaceId = $workspaceId;
72
73 $this->workspaceEnabled = BackendUtility::isTableWorkspaceEnabled($tableName);
74 }
75
76 /**
77 * Cleans a slug value so it is used directly in the path segment of a URL.
78 *
79 * @param string $slug
80 * @return string
81 */
82 public function sanitize(string $slug): string
83 {
84 // Convert to lowercase + remove tags
85 $slug = mb_strtolower($slug, 'utf-8');
86 $slug = strip_tags($slug);
87
88 // Convert some special tokens (space, "_" and "-") to the space character
89 $fallbackCharacter = (string)($this->configuration['fallbackCharacter'] ?? '-');
90 $slug = preg_replace('/[ \t\x{00A0}\-+_]+/u', $fallbackCharacter, $slug);
91
92 // Convert extended letters to ascii equivalents
93 $slug = GeneralUtility::makeInstance(CharsetConverter::class)->specCharsToASCII('utf-8', $slug);
94
95 // Get rid of all invalid characters, but allow slashes
96 $slug = preg_replace('/[^\p{L}0-9\/' . preg_quote($fallbackCharacter) . ']/u', '', $slug);
97
98 // Convert multiple fallback characters to a single one
99 if ($fallbackCharacter !== '') {
100 $slug = preg_replace('/' . preg_quote($fallbackCharacter) . '{2,}/', $fallbackCharacter, $slug);
101 }
102
103 // Ensure slug is lower cased after all replacement was done:
104 // The specCharsToASCII() above for example converts "€" to "EUR"
105 $slug = mb_strtolower($slug, 'utf-8');
106 // keep slashes: re-convert them after rawurlencode did everything
107 $slug = rawurlencode($slug);
108 // @todo: add a test and see if we need this
109 $slug = str_replace('%2F', '/', $slug);
110 // Extract slug, thus it does not have wrapping fallback and slash characters
111 $extractedSlug = $this->extract($slug);
112 // Remove trailing and beginning slashes, except if the trailing slash was added, then we'll re-add it
113 $appendTrailingSlash = $extractedSlug !== '' && substr($slug, -1) === '/';
114 $slug = '/' . $extractedSlug . ($appendTrailingSlash ? '/' : '');
115 return $slug;
116 }
117
118 /**
119 * Extracts payload of slug and removes wrapping delimiters,
120 * e.g. `/hello/world/` will become `hello/world`.
121 *
122 * @param string $slug
123 * @return string
124 */
125 public function extract(string $slug): string
126 {
127 // Convert some special tokens (space, "_" and "-") to the space character
128 $fallbackCharacter = $this->configuration['fallbackCharacter'] ?? '-';
129 return trim($slug, $fallbackCharacter . '/');
130 }
131
132 /**
133 * Used when no slug exists for a record
134 *
135 * @param array $recordData
136 * @param int $pid
137 * @return string
138 */
139 public function generate(array $recordData, int $pid): string
140 {
141 if ($pid === 0 || (!empty($recordData['is_siteroot']) && $this->tableName === 'pages')) {
142 return '/';
143 }
144 $prefix = '';
145 $languageId = (int)$recordData[$GLOBALS['TCA'][$this->tableName]['ctrl']['languageField']];
146 if ($this->configuration['generatorOptions']['prefixParentPageSlug'] ?? false) {
147 $rootLine = BackendUtility::BEgetRootLine($pid, '', true, ['nav_title']);
148 $parentPageRecord = reset($rootLine);
149 if ($languageId > 0) {
150 $localizedParentPageRecord = BackendUtility::getRecordLocalization('pages', $parentPageRecord['uid'], $languageId);
151 if (!empty($localizedParentPageRecord)) {
152 $parentPageRecord = reset($localizedParentPageRecord);
153 }
154 }
155 if (is_array($parentPageRecord)) {
156 // If the parent page has a slug, use that instead of "re-generating" the slug from the parents' page title
157 if (!empty($parentPageRecord['slug'])) {
158 $rootLineItemSlug = $parentPageRecord['slug'];
159 } else {
160 $rootLineItemSlug = $this->generate($parentPageRecord, (int)$parentPageRecord['pid']);
161 }
162 $rootLineItemSlug = trim($rootLineItemSlug, '/');
163 if (!empty($rootLineItemSlug)) {
164 $prefix = $rootLineItemSlug;
165 }
166 }
167 }
168
169 $fieldSeparator = $this->configuration['generatorOptions']['fieldSeparator'] ?? '/';
170 $slugParts = [];
171 foreach ($this->configuration['generatorOptions']['fields'] ?? [] as $fieldName) {
172 if (!empty($recordData[$fieldName])) {
173 $slugParts[] = $recordData[$fieldName];
174 }
175 }
176 $slug = implode($fieldSeparator, $slugParts);
177 if (!empty($prefix)) {
178 $slug = $prefix . '/' . $slug;
179 }
180
181 return $this->sanitize($slug);
182 }
183
184 /**
185 * Checks if there are other records with the same slug that are located on the same PID.
186 *
187 * @param string $slug
188 * @param string|int $recordId
189 * @param int $pageId
190 * @param int $languageId
191 * @return bool
192 */
193 public function isUniqueInPid(string $slug, $recordId, int $pageId, int $languageId): bool
194 {
195 if ($pageId < 0) {
196 $pageId = $this->resolveLivePageId($recordId);
197 }
198
199 $queryBuilder = $this->createPreparedQueryBuilder();
200 $this->applySlugConstraint($queryBuilder, $slug);
201 $this->applyPageIdConstraint($queryBuilder, $pageId);
202 $this->applyRecordConstraint($queryBuilder, $recordId);
203 $this->applyLanguageConstraint($queryBuilder, $languageId);
204 $this->applyWorkspaceConstraint($queryBuilder);
205 $statement = $queryBuilder->execute();
206 return $statement->rowCount() === 0;
207 }
208
209 /**
210 * Check if there are other records with the same slug that are located on the same site.
211 *
212 * @param string $slug
213 * @param string|int $recordId
214 * @param int $pageId
215 * @param int $languageId
216 * @return bool
217 */
218 public function isUniqueInSite(string $slug, $recordId, int $pageId, int $languageId): bool
219 {
220 if ($pageId < 0) {
221 $pageId = $this->resolveLivePageId($recordId);
222 }
223
224 $queryBuilder = $this->createPreparedQueryBuilder();
225 $this->applySlugConstraint($queryBuilder, $slug);
226 $this->applyRecordConstraint($queryBuilder, $recordId);
227 $this->applyLanguageConstraint($queryBuilder, $languageId);
228 $this->applyWorkspaceConstraint($queryBuilder);
229 $statement = $queryBuilder->execute();
230
231 $records = $statement->fetchAll();
232 if (count($records) === 0) {
233 return true;
234 }
235
236 // The installation contains at least ONE other record with the same slug
237 // Now find out if it is the same root page ID
238 $siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
239 $siteOfCurrentRecord = $siteMatcher->matchByPageId($pageId);
240 foreach ($records as $record) {
241 $siteOfExistingRecord = $siteMatcher->matchByPageId((int)$record['uid']);
242 if ($siteOfExistingRecord->getRootPageId() === $siteOfCurrentRecord->getRootPageId()) {
243 return false;
244 }
245 }
246
247 // Otherwise, everything is still fine
248 return true;
249 }
250
251 /**
252 * Generate a slug with a suffix "/mytitle-1" if that is in use already.
253 *
254 * @param string $slug proposed slug
255 * @param mixed $recordId can be a new record (non-int) or an existing record ID
256 * @param int $realPid pageID (already workspace-resolved)
257 * @param int $languageId the language ID realm to be searched for
258 * @return string
259 */
260 public function buildSlugForUniqueInSite(string $slug, $recordId, int $realPid, int $languageId): string
261 {
262 $slug = $this->sanitize($slug);
263 $rawValue = $this->extract($slug);
264 $newValue = $slug;
265 $counter = 0;
266 while (!$this->isUniqueInSite(
267 $newValue,
268 $recordId,
269 $realPid,
270 $languageId
271 ) && $counter++ < 100
272 ) {
273 $newValue = $this->sanitize($rawValue . '-' . $counter);
274 }
275 if ($counter === 100) {
276 $newValue = $this->sanitize($rawValue . '-' . GeneralUtility::shortMD5($rawValue));
277 }
278 return $newValue;
279 }
280
281 /**
282 * Generate a slug with a suffix "/mytitle-1" if the suggested slug is in use already.
283 *
284 * @param string $slug proposed slug
285 * @param mixed $recordId can be a new record (non-int) or an existing record ID
286 * @param int $realPid pageID (already workspace-resolved)
287 * @param int $languageId the language ID realm to be searched for
288 * @return string
289 */
290 public function buildSlugForUniqueInPid(string $slug, $recordId, int $realPid, int $languageId): string
291 {
292 $slug = $this->sanitize($slug);
293 $rawValue = $this->extract($slug);
294 $newValue = $slug;
295 $counter = 0;
296 while (!$this->isUniqueInPid(
297 $newValue,
298 $recordId,
299 $realPid,
300 $languageId
301 ) && $counter++ < 100
302 ) {
303 $newValue = $this->sanitize($rawValue . '-' . $counter);
304 }
305 if ($counter === 100) {
306 $newValue = $this->sanitize($rawValue . '-' . GeneralUtility::shortMD5($rawValue));
307 }
308 return $newValue;
309 }
310
311 /**
312 * @return QueryBuilder
313 */
314 protected function createPreparedQueryBuilder(): QueryBuilder
315 {
316 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->tableName);
317 $queryBuilder->getRestrictions()
318 ->removeAll()
319 ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
320 $queryBuilder
321 ->select('uid', 'pid', $this->fieldName)
322 ->from($this->tableName);
323 return $queryBuilder;
324 }
325
326 /**
327 * @param QueryBuilder $queryBuilder
328 */
329 protected function applyWorkspaceConstraint(QueryBuilder $queryBuilder)
330 {
331 if (!$this->workspaceEnabled) {
332 return;
333 }
334
335 $workspaceIds = [0];
336 if ($this->workspaceId > 0) {
337 $workspaceIds[] = $this->workspaceId;
338 }
339 $queryBuilder->andWhere(
340 $queryBuilder->expr()->in(
341 't3ver_wsid',
342 $queryBuilder->createNamedParameter($workspaceIds, Connection::PARAM_INT_ARRAY)
343 )
344 );
345 }
346
347 /**
348 * @param QueryBuilder $queryBuilder
349 * @param int $languageId
350 */
351 protected function applyLanguageConstraint(QueryBuilder $queryBuilder, int $languageId)
352 {
353 $languageField = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
354 if (!is_string($languageField)) {
355 return;
356 }
357
358 // Only check records of the given language
359 $queryBuilder->andWhere(
360 $queryBuilder->expr()->eq(
361 $languageField,
362 $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
363 )
364 );
365 }
366
367 /**
368 * @param QueryBuilder $queryBuilder
369 * @param string $slug
370 */
371 protected function applySlugConstraint(QueryBuilder $queryBuilder, string $slug)
372 {
373 $queryBuilder->where(
374 $queryBuilder->expr()->eq(
375 $this->fieldName,
376 $queryBuilder->createNamedParameter($slug)
377 )
378 );
379 }
380
381 /**
382 * @param QueryBuilder $queryBuilder
383 * @param int $pageId
384 */
385 protected function applyPageIdConstraint(QueryBuilder $queryBuilder, int $pageId)
386 {
387 if ($pageId < 0) {
388 throw new \RuntimeException(
389 sprintf(
390 'Page id must be positive "%d"',
391 $pageId
392 ),
393 1534962573
394 );
395 }
396
397 $queryBuilder->andWhere(
398 $queryBuilder->expr()->eq(
399 'pid',
400 $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
401 )
402 );
403 }
404
405 /**
406 * @param QueryBuilder $queryBuilder
407 * @param string|int $recordId
408 */
409 protected function applyRecordConstraint(QueryBuilder $queryBuilder, $recordId)
410 {
411 // Exclude the current record if it is an existing record
412 if (!MathUtility::canBeInterpretedAsInteger($recordId)) {
413 return;
414 }
415
416 $queryBuilder->andWhere(
417 $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($recordId, \PDO::PARAM_INT))
418 );
419 if ($this->workspaceId > 0 && $this->workspaceEnabled) {
420 $liveId = BackendUtility::getLiveVersionIdOfRecord($this->tableName, $recordId) ?? $recordId;
421 $queryBuilder->andWhere(
422 $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($liveId, \PDO::PARAM_INT))
423 );
424 }
425 }
426
427 /**
428 * @param int $recordId
429 * @return int
430 * @throws \RuntimeException
431 */
432 protected function resolveLivePageId($recordId): int
433 {
434 if (!MathUtility::canBeInterpretedAsInteger($recordId)) {
435 throw new \RuntimeException(
436 sprintf(
437 'Cannot resolve live page id for non-numeric identifier "%s"',
438 $recordId
439 ),
440 1534951024
441 );
442 }
443
444 $liveVersion = BackendUtility::getLiveVersionOfRecord(
445 $this->tableName,
446 $recordId,
447 'pid'
448 );
449
450 if (empty($liveVersion)) {
451 throw new \RuntimeException(
452 sprintf(
453 'Cannot resolve live page id for record "%s:%d"',
454 $this->tableName,
455 $recordId
456 ),
457 1534951025
458 );
459 }
460
461 return (int)$liveVersion['pid'];
462 }
463 }