[BUGFIX] Fix sql error in EXT:linkvalidator
[Packages/TYPO3.CMS.git] / typo3 / sysext / linkvalidator / Classes / LinkAnalyzer.php
1 <?php
2 namespace TYPO3\CMS\Linkvalidator;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Backend\Utility\BackendUtility;
18 use TYPO3\CMS\Core\Database\ConnectionPool;
19 use TYPO3\CMS\Core\Database\Query\QueryHelper;
20 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
21 use TYPO3\CMS\Core\Html\HtmlParser;
22 use TYPO3\CMS\Core\Utility\GeneralUtility;
23 use TYPO3\CMS\Lang\LanguageService;
24
25 /**
26 * This class provides Processing plugin implementation
27 */
28 class LinkAnalyzer
29 {
30
31 /**
32 * Array of tables and fields to search for broken links
33 *
34 * @var array
35 */
36 protected $searchFields = array();
37
38 /**
39 * List of comma separated page uids (rootline downwards)
40 *
41 * @var string
42 */
43 protected $pidList = '';
44
45 /**
46 * Array of tables and the number of external links they contain
47 *
48 * @var array
49 */
50 protected $linkCounts = array();
51
52 /**
53 * Array of tables and the number of broken external links they contain
54 *
55 * @var array
56 */
57 protected $brokenLinkCounts = array();
58
59 /**
60 * Array of tables and records containing broken links
61 *
62 * @var array
63 */
64 protected $recordsWithBrokenLinks = array();
65
66 /**
67 * Array for hooks for own checks
68 *
69 * @var \TYPO3\CMS\Linkvalidator\Linktype\AbstractLinktype[]
70 */
71 protected $hookObjectsArr = array();
72
73 /**
74 * Array with information about the current page
75 *
76 * @var array
77 */
78 protected $extPageInTreeInfo = array();
79
80 /**
81 * Reference to the current element with table:uid, e.g. pages:85
82 *
83 * @var string
84 */
85 protected $recordReference = '';
86
87 /**
88 * Linked page together with a possible anchor, e.g. 85#c105
89 *
90 * @var string
91 */
92 protected $pageWithAnchor = '';
93
94 /**
95 * The currently active TSConfig. Will be passed to the init function.
96 *
97 * @var array
98 */
99 protected $tsConfig = array();
100
101 /**
102 * Fill hookObjectsArr with different link types and possible XClasses.
103 */
104 public function __construct()
105 {
106 $this->getLanguageService()->includeLLFile('EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf');
107 // Hook to handle own checks
108 if (is_array($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['linkvalidator']['checkLinks'])) {
109 foreach ($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['linkvalidator']['checkLinks'] as $key => $classRef) {
110 $this->hookObjectsArr[$key] = GeneralUtility::getUserObj($classRef);
111 }
112 }
113 }
114
115 /**
116 * Store all the needed configuration values in class variables
117 *
118 * @param array $searchField List of fields in which to search for links
119 * @param string $pid List of comma separated page uids in which to search for links
120 * @param array $tsConfig The currently active TSConfig.
121 * @return void
122 */
123 public function init(array $searchField, $pid, $tsConfig)
124 {
125 $this->searchFields = $searchField;
126 $this->pidList = $pid;
127 $this->tsConfig = $tsConfig;
128 }
129
130 /**
131 * Find all supported broken links and store them in tx_linkvalidator_link
132 *
133 * @param array $checkOptions List of hook object to activate
134 * @param bool $considerHidden Defines whether to look into hidden fields
135 * @return void
136 */
137 public function getLinkStatistics($checkOptions = array(), $considerHidden = false)
138 {
139 $results = [];
140 $pidList = GeneralUtility::intExplode(',', $this->pidList, true);
141 if (!empty($checkOptions) && !empty($pidList)) {
142 $checkKeys = array_keys($checkOptions);
143
144 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
145 ->getQueryBuilderForTable('tx_linkvalidator_link');
146
147 $queryBuilder->delete('tx_linkvalidator_link')
148 ->where(
149 $queryBuilder->expr()->orX(
150 $queryBuilder->expr()->in('record_pid', $pidList),
151 $queryBuilder->expr()->andX(
152 $queryBuilder->expr()->in('record_uid', $pidList),
153 $queryBuilder->expr()->eq('table_name', $queryBuilder->quote('pages'))
154 )
155 ),
156 $queryBuilder->expr()->in(
157 'link_type',
158 array_map([$queryBuilder, 'createNamedParameter'], $checkKeys)
159 )
160 )
161 ->execute();
162
163 // Traverse all configured tables
164 foreach ($this->searchFields as $table => $fields) {
165 // If table is not configured, assume the extension is not installed
166 // and therefore no need to check it
167 if (!is_array($GLOBALS['TCA'][$table])) {
168 continue;
169 }
170 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
171 ->getQueryBuilderForTable($table);
172
173 if ($considerHidden) {
174 $queryBuilder->getRestrictions()
175 ->removeAll()
176 ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
177 }
178
179 // Re-init selectFields for table
180 $selectFields = array_merge(['uid', 'pid', $GLOBALS['TCA'][$table]['ctrl']['label']], $fields);
181
182 $result = $queryBuilder->select(...$selectFields)
183 ->from($table)
184 ->where($queryBuilder->expr()->in(($table === 'pages' ? 'uid' : 'pid'), $pidList))
185 ->execute();
186
187 // @todo #64091: only select rows that have content in at least one of the relevant fields (via OR)
188 while ($row = $result->fetch()) {
189 $this->analyzeRecord($results, $table, $fields, $row);
190 }
191 }
192
193 foreach ($this->hookObjectsArr as $key => $hookObj) {
194 if (is_array($results[$key]) && empty($checkOptions) || is_array($results[$key]) && $checkOptions[$key]) {
195 // Check them
196 foreach ($results[$key] as $entryKey => $entryValue) {
197 $table = $entryValue['table'];
198 $record = [];
199 $record['headline'] = BackendUtility::getRecordTitle($table, $entryValue['row']);
200 $record['record_pid'] = $entryValue['row']['pid'];
201 $record['record_uid'] = $entryValue['uid'];
202 $record['table_name'] = $table;
203 $record['link_title'] = $entryValue['link_title'];
204 $record['field'] = $entryValue['field'];
205 $record['last_check'] = time();
206 $this->recordReference = $entryValue['substr']['recordRef'];
207 $this->pageWithAnchor = $entryValue['pageAndAnchor'];
208 if (!empty($this->pageWithAnchor)) {
209 // Page with anchor, e.g. 18#1580
210 $url = $this->pageWithAnchor;
211 } else {
212 $url = $entryValue['substr']['tokenValue'];
213 }
214 $this->linkCounts[$table]++;
215 $checkUrl = $hookObj->checkLink($url, $entryValue, $this);
216 // Broken link found
217 if (!$checkUrl) {
218 $response = [];
219 $response['valid'] = false;
220 $response['errorParams'] = $hookObj->getErrorParams();
221 $this->brokenLinkCounts[$table]++;
222 $record['link_type'] = $key;
223 $record['url'] = $url;
224 $record['url_response'] = serialize($response);
225 GeneralUtility::makeInstance(ConnectionPool::class)
226 ->getConnectionForTable('tx_linkvalidator_link')
227 ->insert('tx_linkvalidator_link', $record);
228 } elseif (GeneralUtility::_GP('showalllinks')) {
229 $response = [];
230 $response['valid'] = true;
231 $this->brokenLinkCounts[$table]++;
232 $record['url'] = $url;
233 $record['link_type'] = $key;
234 $record['url_response'] = serialize($response);
235 GeneralUtility::makeInstance(ConnectionPool::class)
236 ->getConnectionForTable('tx_linkvalidator_link')
237 ->insert('tx_linkvalidator_link', $record);
238 }
239 }
240 }
241 }
242 }
243 }
244
245 /**
246 * Find all supported broken links for a specific record
247 *
248 * @param array $results Array of broken links
249 * @param string $table Table name of the record
250 * @param array $fields Array of fields to analyze
251 * @param array $record Record to analyse
252 * @return void
253 */
254 public function analyzeRecord(array &$results, $table, array $fields, array $record)
255 {
256 list($results, $record) = $this->emitBeforeAnalyzeRecordSignal($results, $record, $table, $fields);
257
258 // Put together content of all relevant fields
259 $haystack = '';
260 /** @var $htmlParser HtmlParser */
261 $htmlParser = GeneralUtility::makeInstance(HtmlParser::class);
262 $idRecord = $record['uid'];
263 // Get all references
264 foreach ($fields as $field) {
265 $haystack .= $record[$field] . ' --- ';
266 $conf = $GLOBALS['TCA'][$table]['columns'][$field]['config'];
267 $valueField = $record[$field];
268 // Check if a TCA configured field has soft references defined (see TYPO3 Core API document)
269 if ($conf['softref'] && (string)$valueField !== '') {
270 // Explode the list of soft references/parameters
271 $softRefs = BackendUtility::explodeSoftRefParserList($conf['softref']);
272 if ($softRefs !== false) {
273 // Traverse soft references
274 foreach ($softRefs as $spKey => $spParams) {
275 /** @var $softRefObj \TYPO3\CMS\Core\Database\SoftReferenceIndex */
276 $softRefObj = BackendUtility::softRefParserObj($spKey);
277 // If there is an object returned...
278 if (is_object($softRefObj)) {
279 // Do processing
280 $resultArray = $softRefObj->findRef($table, $field, $idRecord, $valueField, $spKey, $spParams);
281 if (!empty($resultArray['elements'])) {
282 if ($spKey == 'typolink_tag') {
283 $this->analyseTypoLinks($resultArray, $results, $htmlParser, $record, $field, $table);
284 } else {
285 $this->analyseLinks($resultArray, $results, $record, $field, $table);
286 }
287 }
288 }
289 }
290 }
291 }
292 }
293 }
294
295 /**
296 * Returns the TSConfig that was passed to the init() method.
297 *
298 * This can be used by link checkers that get a reference of this
299 * object passed to the checkLink() method.
300 *
301 * @return array
302 */
303 public function getTSConfig()
304 {
305 return $this->tsConfig;
306 }
307
308 /**
309 * Find all supported broken links for a specific link list
310 *
311 * @param array $resultArray findRef parsed records
312 * @param array $results Array of broken links
313 * @param array $record UID of the current record
314 * @param string $field The current field
315 * @param string $table The current table
316 * @return void
317 */
318 protected function analyseLinks(array $resultArray, array &$results, array $record, $field, $table)
319 {
320 foreach ($resultArray['elements'] as $element) {
321 $r = $element['subst'];
322 $type = '';
323 $idRecord = $record['uid'];
324 if (!empty($r)) {
325 /** @var $hookObj \TYPO3\CMS\Linkvalidator\Linktype\AbstractLinktype */
326 foreach ($this->hookObjectsArr as $keyArr => $hookObj) {
327 $type = $hookObj->fetchType($r, $type, $keyArr);
328 // Store the type that was found
329 // This prevents overriding by internal validator
330 if (!empty($type)) {
331 $r['type'] = $type;
332 }
333 }
334 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $r['tokenID']]['substr'] = $r;
335 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $r['tokenID']]['row'] = $record;
336 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $r['tokenID']]['table'] = $table;
337 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $r['tokenID']]['field'] = $field;
338 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $r['tokenID']]['uid'] = $idRecord;
339 }
340 }
341 }
342
343 /**
344 * Find all supported broken links for a specific typoLink
345 *
346 * @param array $resultArray findRef parsed records
347 * @param array $results Array of broken links
348 * @param HtmlParser $htmlParser Instance of html parser
349 * @param array $record The current record
350 * @param string $field The current field
351 * @param string $table The current table
352 * @return void
353 */
354 protected function analyseTypoLinks(array $resultArray, array &$results, $htmlParser, array $record, $field, $table)
355 {
356 $currentR = array();
357 $linkTags = $htmlParser->splitIntoBlock('link', $resultArray['content']);
358 $idRecord = $record['uid'];
359 $type = '';
360 $title = '';
361 $countLinkTags = count($linkTags);
362 for ($i = 1; $i < $countLinkTags; $i += 2) {
363 $referencedRecordType = '';
364 foreach ($resultArray['elements'] as $element) {
365 $type = '';
366 $r = $element['subst'];
367 if (!empty($r['tokenID'])) {
368 if (substr_count($linkTags[$i], $r['tokenID'])) {
369 // Type of referenced record
370 if (strpos($r['recordRef'], 'pages') !== false) {
371 $currentR = $r;
372 // Contains number of the page
373 $referencedRecordType = $r['tokenValue'];
374 $wasPage = true;
375 } elseif (strpos($r['recordRef'], 'tt_content') !== false && (isset($wasPage) && $wasPage === true)) {
376 $referencedRecordType = $referencedRecordType . '#c' . $r['tokenValue'];
377 $wasPage = false;
378 } else {
379 $currentR = $r;
380 }
381 $title = strip_tags($linkTags[$i]);
382 }
383 }
384 }
385 /** @var $hookObj \TYPO3\CMS\Linkvalidator\Linktype\AbstractLinktype */
386 foreach ($this->hookObjectsArr as $keyArr => $hookObj) {
387 $type = $hookObj->fetchType($currentR, $type, $keyArr);
388 // Store the type that was found
389 // This prevents overriding by internal validator
390 if (!empty($type)) {
391 $currentR['type'] = $type;
392 }
393 }
394 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $currentR['tokenID']]['substr'] = $currentR;
395 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $currentR['tokenID']]['row'] = $record;
396 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $currentR['tokenID']]['table'] = $table;
397 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $currentR['tokenID']]['field'] = $field;
398 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $currentR['tokenID']]['uid'] = $idRecord;
399 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $currentR['tokenID']]['link_title'] = $title;
400 $results[$type][$table . ':' . $field . ':' . $idRecord . ':' . $currentR['tokenID']]['pageAndAnchor'] = $referencedRecordType;
401 }
402 }
403
404 /**
405 * Fill a marker array with the number of links found in a list of pages
406 *
407 * @param string $curPage Comma separated list of page uids
408 * @return array Marker array with the number of links found
409 */
410 public function getLinkCounts($curPage)
411 {
412 $markerArray = [];
413 $this->pidList = GeneralUtility::intExplode(',', ($this->pidList ?: $curPage), true);
414
415 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
416 ->getQueryBuilderForTable('tx_linkvalidator_link');
417 $queryBuilder->getRestrictions()->removeAll();
418
419 $result = $queryBuilder->select('link_type')
420 ->addSelectLiteral($queryBuilder->expr()->count('uid', 'nbBrokenLinks'))
421 ->from('tx_linkvalidator_link')
422 ->where($queryBuilder->expr()->in('record_pid', $this->pidList))
423 ->groupBy('link_type')
424 ->execute();
425
426 while ($row = $result->fetch()) {
427 $markerArray[$row['link_type']] = $row['nbBrokenLinks'];
428 $markerArray['brokenlinkCount'] += $row['nbBrokenLinks'];
429 }
430 return $markerArray;
431 }
432
433 /**
434 * Calls TYPO3\CMS\Backend\FrontendBackendUserAuthentication::extGetTreeList.
435 * Although this duplicates the function TYPO3\CMS\Backend\FrontendBackendUserAuthentication::extGetTreeList
436 * this is necessary to create the object that is used recursively by the original function.
437 *
438 * Generates a list of page uids from $id. List does not include $id itself.
439 * The only pages excluded from the list are deleted pages.
440 *
441 * @param int $id Start page id
442 * @param int $depth Depth to traverse down the page tree.
443 * @param int $begin is an optional integer that determines at which
444 * @param string $permsClause Perms clause
445 * @param bool $considerHidden Whether to consider hidden pages or not
446 * @return string Returns the list with a comma in the end (if any pages selected!)
447 */
448 public function extGetTreeList($id, $depth, $begin = 0, $permsClause, $considerHidden = false)
449 {
450 $depth = (int)$depth;
451 $begin = (int)$begin;
452 $id = (int)$id;
453 $theList = '';
454 if ($depth > 0) {
455 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
456 $queryBuilder->getRestrictions()
457 ->removeAll()
458 ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
459
460 $result = $queryBuilder
461 ->select('uid', 'title', 'hidden', 'extendToSubpages')
462 ->from('pages')
463 ->where(
464 $queryBuilder->expr()->eq('pid', $id),
465 QueryHelper::stripLogicalOperatorPrefix($permsClause)
466 )
467 ->execute();
468
469 while ($row = $result->fetch()) {
470 if ($begin <= 0 && ($row['hidden'] == 0 || $considerHidden)) {
471 $theList .= $row['uid'] . ',';
472 $this->extPageInTreeInfo[] = [$row['uid'], htmlspecialchars($row['title'], $depth)];
473 }
474 if ($depth > 1 && (!($row['hidden'] == 1 && $row['extendToSubpages'] == 1) || $considerHidden)) {
475 $theList .= $this->extGetTreeList(
476 $row['uid'],
477 $depth - 1,
478 $begin - 1,
479 $permsClause,
480 $considerHidden
481 );
482 }
483 }
484 }
485 return $theList;
486 }
487
488 /**
489 * Check if rootline contains a hidden page
490 *
491 * @param array $pageInfo Array with uid, title, hidden, extendToSubpages from pages table
492 * @return bool TRUE if rootline contains a hidden page, FALSE if not
493 */
494 public function getRootLineIsHidden(array $pageInfo)
495 {
496 $hidden = false;
497 if ($pageInfo['extendToSubpages'] == 1 && $pageInfo['hidden'] == 1) {
498 $hidden = true;
499 } else {
500 if ($pageInfo['pid'] > 0) {
501 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
502 $queryBuilder->getRestrictions()->removeAll();
503
504 $row = $queryBuilder
505 ->select('uid', 'title', 'hidden', 'extendToSubpages')
506 ->from('pages')
507 ->where($queryBuilder->expr()->eq('uid', $pageInfo['pid']))
508 ->execute()
509 ->fetch();
510
511 if ($row !== false) {
512 $hidden = $this->getRootLineIsHidden($row);
513 }
514 }
515 }
516
517 return $hidden;
518 }
519
520 /**
521 * Emits a signal before the record is analyzed
522 *
523 * @param array $results Array of broken links
524 * @param array $record Record to analyse
525 * @param string $table Table name of the record
526 * @param array $fields Array of fields to analyze
527 * @return array
528 */
529 protected function emitBeforeAnalyzeRecordSignal($results, $record, $table, $fields)
530 {
531 return $this->getSignalSlotDispatcher()->dispatch(
532 self::class,
533 'beforeAnalyzeRecord',
534 array($results, $record, $table, $fields, $this)
535 );
536 }
537
538 /**
539 * @return \TYPO3\CMS\Extbase\SignalSlot\Dispatcher
540 */
541 protected function getSignalSlotDispatcher()
542 {
543 return $this->getObjectManager()->get(\TYPO3\CMS\Extbase\SignalSlot\Dispatcher::class);
544 }
545
546 /**
547 * @return \TYPO3\CMS\Extbase\Object\ObjectManager
548 */
549 protected function getObjectManager()
550 {
551 return GeneralUtility::makeInstance(\TYPO3\CMS\Extbase\Object\ObjectManager::class);
552 }
553
554 /**
555 * @return LanguageService
556 */
557 protected function getLanguageService()
558 {
559 return $GLOBALS['LANG'];
560 }
561 }