e2a8369fe702f403cb3f07171a62e6f870d63ede
[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 // Remove trailing and beginning slashes, except if the trailing slash was added, then we'll re-add it
111 $appendTrailingSlash = substr($slug, -1) === '/';
112 $slug = '/' . $this->extract($slug) . ($appendTrailingSlash ? '/' : '');
113 return $slug;
114 }
115
116 /**
117 * Extracts payload of slug and removes wrapping delimiters,
118 * e.g. `/hello/world/` will become `hello/world`.
119 *
120 * @param string $slug
121 * @return string
122 */
123 public function extract(string $slug): string
124 {
125 // Convert some special tokens (space, "_" and "-") to the space character
126 $fallbackCharacter = $this->configuration['fallbackCharacter'] ?? '-';
127 return trim($slug, $fallbackCharacter . '/');
128 }
129
130 /**
131 * Used when no slug exists for a record
132 *
133 * @param array $recordData
134 * @param int $pid
135 * @return string
136 */
137 public function generate(array $recordData, int $pid): string
138 {
139 if ($pid === 0 || (!empty($recordData['is_siteroot']) && $this->tableName === 'pages')) {
140 return '/';
141 }
142 $prefix = '';
143 $languageId = (int)$recordData[$GLOBALS['TCA'][$this->tableName]['ctrl']['languageField']];
144 if ($this->configuration['generatorOptions']['prefixParentPageSlug'] ?? false) {
145 $rootLine = BackendUtility::BEgetRootLine($pid, '', true, ['nav_title']);
146 $parentPageRecord = reset($rootLine);
147 if ($languageId > 0) {
148 $localizedParentPageRecord = BackendUtility::getRecordLocalization('pages', $parentPageRecord['uid'], $languageId);
149 if (!empty($localizedParentPageRecord)) {
150 $parentPageRecord = reset($localizedParentPageRecord);
151 }
152 }
153 if (is_array($parentPageRecord)) {
154 // If the parent page has a slug, use that instead of "re-generating" the slug from the parents' page title
155 if (!empty($parentPageRecord['slug'])) {
156 $rootLineItemSlug = $parentPageRecord['slug'];
157 } else {
158 $rootLineItemSlug = $this->generate($parentPageRecord, (int)$parentPageRecord['pid']);
159 }
160 $rootLineItemSlug = trim($rootLineItemSlug, '/');
161 if (!empty($rootLineItemSlug)) {
162 $prefix = $rootLineItemSlug;
163 }
164 }
165 }
166
167 $fieldSeparator = $this->configuration['generatorOptions']['fieldSeparator'] ?? '/';
168 $slugParts = [];
169 foreach ($this->configuration['generatorOptions']['fields'] ?? [] as $fieldName) {
170 if (!empty($recordData[$fieldName])) {
171 $slugParts[] = $recordData[$fieldName];
172 }
173 }
174 $slug = implode($fieldSeparator, $slugParts);
175 if (!empty($prefix)) {
176 $slug = $prefix . '/' . $slug;
177 }
178
179 return $this->sanitize($slug);
180 }
181
182 /**
183 * Checks if there are other records with the same slug that are located on the same PID.
184 *
185 * @param string $slug
186 * @param string|int $recordId
187 * @param int $pageId
188 * @param int $languageId
189 * @return bool
190 */
191 public function isUniqueInPid(string $slug, $recordId, int $pageId, int $languageId): bool
192 {
193 if ($pageId < 0) {
194 $pageId = $this->resolveLivePageId($recordId);
195 }
196
197 $queryBuilder = $this->createPreparedQueryBuilder();
198 $this->applySlugConstraint($queryBuilder, $slug);
199 $this->applyPageIdConstraint($queryBuilder, $pageId);
200 $this->applyRecordConstraint($queryBuilder, $recordId);
201 $this->applyLanguageConstraint($queryBuilder, $languageId);
202 $this->applyWorkspaceConstraint($queryBuilder);
203 $statement = $queryBuilder->execute();
204 return $statement->rowCount() === 0;
205 }
206
207 /**
208 * Check if there are other records with the same slug that are located on the same site.
209 *
210 * @param string $slug
211 * @param string|int $recordId
212 * @param int $pageId
213 * @param int $languageId
214 * @return bool
215 */
216 public function isUniqueInSite(string $slug, $recordId, int $pageId, int $languageId): bool
217 {
218 if ($pageId < 0) {
219 $pageId = $this->resolveLivePageId($recordId);
220 }
221
222 $queryBuilder = $this->createPreparedQueryBuilder();
223 $this->applySlugConstraint($queryBuilder, $slug);
224 $this->applyRecordConstraint($queryBuilder, $recordId);
225 $this->applyLanguageConstraint($queryBuilder, $languageId);
226 $this->applyWorkspaceConstraint($queryBuilder);
227 $statement = $queryBuilder->execute();
228
229 $records = $statement->fetchAll();
230 if (count($records) === 0) {
231 return true;
232 }
233
234 // The installation contains at least ONE other record with the same slug
235 // Now find out if it is the same root page ID
236 $siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
237 $siteOfCurrentRecord = $siteMatcher->matchByPageId($pageId);
238 foreach ($records as $record) {
239 $siteOfExistingRecord = $siteMatcher->matchByPageId((int)$record['uid']);
240 if ($siteOfExistingRecord->getRootPageId() === $siteOfCurrentRecord->getRootPageId()) {
241 return false;
242 }
243 }
244
245 // Otherwise, everything is still fine
246 return true;
247 }
248
249 /**
250 * Generate a slug with a suffix "/mytitle-1" if that is in use already.
251 *
252 * @param string $slug proposed slug
253 * @param mixed $recordId can be a new record (non-int) or an existing record ID
254 * @param int $realPid pageID (already workspace-resolved)
255 * @param int $languageId the language ID realm to be searched for
256 * @return string
257 */
258 public function buildSlugForUniqueInSite(string $slug, $recordId, int $realPid, int $languageId): string
259 {
260 $slug = $this->sanitize($slug);
261 $rawValue = $this->extract($slug);
262 $newValue = $slug;
263 $counter = 0;
264 while (!$this->isUniqueInSite(
265 $newValue,
266 $recordId,
267 $realPid,
268 $languageId
269 ) && $counter++ < 100
270 ) {
271 $newValue = $this->sanitize($rawValue . '-' . $counter);
272 }
273 if ($counter === 100) {
274 $newValue = $this->sanitize($rawValue . '-' . GeneralUtility::shortMD5($rawValue));
275 }
276 return $newValue;
277 }
278
279 /**
280 * Generate a slug with a suffix "/mytitle-1" if the suggested slug is in use already.
281 *
282 * @param string $slug proposed slug
283 * @param mixed $recordId can be a new record (non-int) or an existing record ID
284 * @param int $realPid pageID (already workspace-resolved)
285 * @param int $languageId the language ID realm to be searched for
286 * @return string
287 */
288 public function buildSlugForUniqueInPid(string $slug, $recordId, int $realPid, int $languageId): string
289 {
290 $slug = $this->sanitize($slug);
291 $rawValue = $this->extract($slug);
292 $newValue = $slug;
293 $counter = 0;
294 while (!$this->isUniqueInPid(
295 $newValue,
296 $recordId,
297 $realPid,
298 $languageId
299 ) && $counter++ < 100
300 ) {
301 $newValue = $this->sanitize($rawValue . '-' . $counter);
302 }
303 if ($counter === 100) {
304 $newValue = $this->sanitize($rawValue . '-' . GeneralUtility::shortMD5($rawValue));
305 }
306 return $newValue;
307 }
308
309 /**
310 * @return QueryBuilder
311 */
312 protected function createPreparedQueryBuilder(): QueryBuilder
313 {
314 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($this->tableName);
315 $queryBuilder->getRestrictions()
316 ->removeAll()
317 ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
318 $queryBuilder
319 ->select('uid', 'pid', $this->fieldName)
320 ->from($this->tableName);
321 return $queryBuilder;
322 }
323
324 /**
325 * @param QueryBuilder $queryBuilder
326 */
327 protected function applyWorkspaceConstraint(QueryBuilder $queryBuilder)
328 {
329 if (!$this->workspaceEnabled) {
330 return;
331 }
332
333 $workspaceIds = [0];
334 if ($this->workspaceId > 0) {
335 $workspaceIds[] = $this->workspaceId;
336 }
337 $queryBuilder->andWhere(
338 $queryBuilder->expr()->in(
339 't3ver_wsid',
340 $queryBuilder->createNamedParameter($workspaceIds, Connection::PARAM_INT_ARRAY)
341 )
342 );
343 }
344
345 /**
346 * @param QueryBuilder $queryBuilder
347 * @param int $languageId
348 */
349 protected function applyLanguageConstraint(QueryBuilder $queryBuilder, int $languageId)
350 {
351 $languageField = $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null;
352 if (!is_string($languageField)) {
353 return;
354 }
355
356 // Only check records of the given language
357 $queryBuilder->andWhere(
358 $queryBuilder->expr()->eq(
359 $languageField,
360 $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
361 )
362 );
363 }
364
365 /**
366 * @param QueryBuilder $queryBuilder
367 * @param string $slug
368 */
369 protected function applySlugConstraint(QueryBuilder $queryBuilder, string $slug)
370 {
371 $queryBuilder->where(
372 $queryBuilder->expr()->eq(
373 $this->fieldName,
374 $queryBuilder->createNamedParameter($slug)
375 )
376 );
377 }
378
379 /**
380 * @param QueryBuilder $queryBuilder
381 * @param int $pageId
382 */
383 protected function applyPageIdConstraint(QueryBuilder $queryBuilder, int $pageId)
384 {
385 if ($pageId < 0) {
386 throw new \RuntimeException(
387 sprintf(
388 'Page id must be positive "%d"',
389 $pageId
390 ),
391 1534962573
392 );
393 }
394
395 $queryBuilder->andWhere(
396 $queryBuilder->expr()->eq(
397 'pid',
398 $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
399 )
400 );
401 }
402
403 /**
404 * @param QueryBuilder $queryBuilder
405 * @param string|int $recordId
406 */
407 protected function applyRecordConstraint(QueryBuilder $queryBuilder, $recordId)
408 {
409 // Exclude the current record if it is an existing record
410 if (!MathUtility::canBeInterpretedAsInteger($recordId)) {
411 return;
412 }
413
414 $queryBuilder->andWhere(
415 $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($recordId, \PDO::PARAM_INT))
416 );
417 if ($this->workspaceId > 0 && $this->workspaceEnabled) {
418 $liveId = BackendUtility::getLiveVersionIdOfRecord($this->tableName, $recordId) ?? $recordId;
419 $queryBuilder->andWhere(
420 $queryBuilder->expr()->neq('uid', $queryBuilder->createNamedParameter($liveId, \PDO::PARAM_INT))
421 );
422 }
423 }
424
425 /**
426 * @param int $recordId
427 * @return int
428 * @throws \RuntimeException
429 */
430 protected function resolveLivePageId($recordId): int
431 {
432 if (!MathUtility::canBeInterpretedAsInteger($recordId)) {
433 throw new \RuntimeException(
434 sprintf(
435 'Cannot resolve live page id for non-numeric identifier "%s"',
436 $recordId
437 ),
438 1534951024
439 );
440 }
441
442 $liveVersion = BackendUtility::getLiveVersionOfRecord(
443 $this->tableName,
444 $recordId,
445 'pid'
446 );
447
448 if (empty($liveVersion)) {
449 throw new \RuntimeException(
450 sprintf(
451 'Cannot resolve live page id for record "%s:%d"',
452 $this->tableName,
453 $recordId
454 ),
455 1534951025
456 );
457 }
458
459 return (int)$liveVersion['pid'];
460 }
461 }