‪TYPO3CMS  ‪main
LinkValidatorController.php
Go to the documentation of this file.
1 <?php
2 
3 declare(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 
19 
20 use Psr\Http\Message\ResponseInterface;
21 use Psr\Http\Message\ServerRequestInterface;
26 use TYPO3\CMS\Backend\Utility\BackendUtility;
30 use TYPO3\CMS\Core\Imaging\IconSize;
41 
47 #[AsController]
49 {
53  protected array ‪$pageRecord = [];
54 
58  protected array ‪$modTS = [];
59 
64  protected array ‪$searchLevel = ['report' => 0, 'check' => 0];
65 
71  protected array ‪$checkOpt = ['report' => [], 'check' => []];
72 
76  protected array ‪$lastEditedRecord = [
77  'uid' => 0,
78  'table' => '',
79  'field' => '',
80  'timestamp' => 0,
81  ];
82 
83  protected int ‪$id;
84  protected array ‪$searchFields = [];
85 
86  protected ServerRequestInterface ‪$request;
87 
88  public function ‪__construct(
89  protected readonly ‪Context $context,
90  protected readonly ‪UriBuilder $uriBuilder,
91  protected readonly ‪IconFactory $iconFactory,
92  protected readonly ‪PagesRepository $pagesRepository,
93  protected readonly ‪BrokenLinkRepository $brokenLinkRepository,
94  protected readonly ‪ModuleTemplateFactory $moduleTemplateFactory,
95  protected readonly ‪LinkAnalyzer $linkAnalyzer,
96  protected readonly ‪LinktypeRegistry $linktypeRegistry,
97  ) {}
98 
99  public function ‪__invoke(ServerRequestInterface ‪$request): ResponseInterface
100  {
101  $backendUser = $this->‪getBackendUser();
102  $languageService = $this->‪getLanguageService();
103 
104  $this->request = ‪$request;
105  $this->id = (int)($this->request->getQueryParams()['id'] ?? 0);
106  $this->modTS = BackendUtility::getPagesTSconfig($this->id)['mod.']['linkvalidator.'] ?? [];
107  $this->pageRecord = BackendUtility::readPageAccess($this->id, $this->‪getBackendUser()->getPagePermsClause(‪Permission::PAGE_SHOW)) ?: [];
108 
109  $view = $this->moduleTemplateFactory->create($this->request);
110  if ($this->pageRecord !== []) {
111  $view->getDocHeaderComponent()->setMetaInformation($this->pageRecord);
112  }
113 
114  $this->‪validateSettings($request);
115  $this->‪initializeLinkAnalyzer();
116 
117  if ($this->request->getParsedBody()['updateLinkList'] ?? false) {
118  $this->‪updateBrokenLinks();
119  } elseif ($this->lastEditedRecord['uid']) {
120  if (($this->modTS['actionAfterEditRecord'] ?? '') === 'recheck') {
121  // recheck broken links for last edited record
122  $this->linkAnalyzer->recheckLinks(
123  $this->checkOpt['check'],
124  $this->lastEditedRecord['uid'],
125  $this->lastEditedRecord['table'],
126  $this->lastEditedRecord['field'],
127  (int)$this->lastEditedRecord['timestamp']
128  );
129  } else {
130  // mark broken links for last edited record as needing a recheck
131  $this->brokenLinkRepository->setNeedsRecheckForRecord(
132  (int)$this->lastEditedRecord['uid'],
133  $this->lastEditedRecord['table']
134  );
135  }
136  }
137 
138  $view->setTitle($this->‪getModuleTitle());
139 
140  if ($backendUser->workspace !== 0 || !(($this->id && $this->pageRecord !== []) || (!$this->id && $backendUser->isAdmin()))) {
141  $view->addFlashMessage($languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:no.access'), $languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:no.access.title'), ContextualFeedbackSeverity::ERROR);
142  return $view->renderResponse('Backend/Empty');
143  }
144 
145  $moduleData = ‪$request->getAttribute('moduleData');
146  if (!($this->modTS['showCheckLinkTab'] ?? false)) {
147  $moduleData->set('action', 'report');
148  $backendUser->pushModuleData($moduleData->getModuleIdentifier(), $moduleData->toArray());
149  } elseif ($moduleData->clean('action', ['report', 'check'])) {
150  $backendUser->pushModuleData($moduleData->getModuleIdentifier(), $moduleData->toArray());
151  }
152  $action = $moduleData->get('action');
153 
154  $this->‪addDocHeaderShortCutButton($view, $action);
155  if ($this->modTS['showCheckLinkTab'] ?? false) {
156  // Add doc header drop down if user is allowed to see both 'report' and 'check'
157  $this->‪addDocHeaderDropDown($view, $action);
158  }
159 
160  if ($action === 'report') {
161  $view->assignMultiple([
162  'title' => $this->pageRecord ? BackendUtility::getRecordTitle('pages', $this->pageRecord) : '',
163  'prefix' => 'report',
164  'selectedLevel' => $this->searchLevel['report'],
165  'options' => $this->‪getCheckOptions('report'),
166  'brokenLinks' => $this->‪getBrokenLinks(),
167  'tableheadPath' => $languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:list.tableHead.path'),
168  'tableheadElement' => $languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:list.tableHead.element'),
169  'tableheadHeadlink' => $languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:list.tableHead.headlink'),
170  'tableheadLinktarget' => $languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:list.tableHead.linktarget'),
171  'tableheadLinkmessage' => $languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:list.tableHead.linkmessage'),
172  'tableheadLastcheck' => $languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:list.tableHead.lastCheck'),
173  ]);
174  return $view->renderResponse('Backend/Report');
175  }
176  $view->assignMultiple([
177  'title' => $this->pageRecord ? BackendUtility::getRecordTitle('pages', $this->pageRecord) : '',
178  'prefix' => 'check',
179  'selectedLevel' => $this->searchLevel['check'],
180  'options' => $this->‪getCheckOptions('check'),
181  ]);
182  return $view->renderResponse('Backend/CheckLinks');
183  }
184 
188  protected function ‪validateSettings(ServerRequestInterface ‪$request): void
189  {
190  $backendUser = $this->‪getBackendUser();
191 
192  $prefix = 'check';
193  $other = 'report';
194  if (empty($this->request->getParsedBody()['updateLinkList'] ?? false)) {
195  $prefix = 'report';
196  $other = 'check';
197  }
198 
199  // get linkvalidator module data
200  $moduleData = ‪$request->getAttribute('moduleData');
201 
202  // get information for last edited record
203  $this->lastEditedRecord['uid'] = $this->request->getQueryParams()['last_edited_record_uid'] ?? 0;
204  $this->lastEditedRecord['table'] = $this->request->getQueryParams()['last_edited_record_table'] ?? '';
205  $this->lastEditedRecord['field'] = $this->request->getQueryParams()['last_edited_record_field'] ?? '';
206  $this->lastEditedRecord['timestamp'] = $this->request->getQueryParams()['last_edited_record_timestamp'] ?? 0;
207 
208  // get searchLevel (number of levels of pages to check / show results)
209  $this->searchLevel[$prefix] = $this->request->getQueryParams()[$prefix . '_search_levels'] ?? $this->request->getParsedBody()[$prefix . '_search_levels'] ?? null;
210 
211  $mainSearchLevelKey = $prefix . '_searchlevel';
212  $otherSearchLevelKey = $other . '_searchlevel';
213  if ($this->searchLevel[$prefix] !== null) {
214  $moduleData->set($mainSearchLevelKey, $this->searchLevel[$prefix]);
215  } else {
216  $this->searchLevel[$prefix] = $moduleData->get($mainSearchLevelKey, 0);
217  }
218  if ($moduleData->has($otherSearchLevelKey)) {
219  $this->searchLevel[$other] = $moduleData->get($otherSearchLevelKey);
220  }
221 
222  // which linkTypes to check (internal, file, external, ...)
223  $set = $this->request->getParsedBody()[$prefix . '_SET'] ?? [];
224  $submittedValues = $this->request->getParsedBody()[$prefix . '_values'] ?? [];
225 
226  foreach ($this->linktypeRegistry->getIdentifiers() as $linkType) {
227  // Compile list of all available types. Used for checking with button "Check Links".
228  unset($this->checkOpt[$prefix][$linkType]);
229  $mainLinkType = $prefix . '_' . $linkType;
230  $otherLinkType = $other . '_' . $linkType;
231 
232  // 1) if "$prefix_values" = "1" : use POST variables
233  // 2) if not set, use stored module configuration
234  // 3) if not set, use default
235  if (!empty($submittedValues)) {
236  $this->checkOpt[$prefix][$linkType] = $set[$linkType] ?? '0';
237  $moduleData->set($mainLinkType, $this->checkOpt[$prefix][$linkType]);
238  } elseif ($moduleData->has($mainLinkType)) {
239  $this->checkOpt[$prefix][$linkType] = $moduleData->get($mainLinkType);
240  } else {
241  // use default
242  $this->checkOpt[$prefix][$linkType] = '0';
243  $moduleData->set($mainLinkType, $this->checkOpt[$prefix][$linkType]);
244  }
245 
246  if ($moduleData->has($otherLinkType)) {
247  $this->checkOpt[$other][$linkType] = $moduleData->get($otherLinkType);
248  }
249  }
250 
251  // save settings
252  $backendUser->pushModuleData($moduleData->getModuleIdentifier(), $moduleData->toArray());
253  }
254 
258  protected function ‪initializeLinkAnalyzer(): void
259  {
260  // Get the searchFields from TSconfig
261  foreach ($this->modTS['searchFields.'] ?? [] as $table => $fieldList) {
262  ‪$fields = ‪GeneralUtility::trimExplode(',', $fieldList, true);
263  foreach (‪$fields as $field) {
264  if (!$this->searchFields
265  || !is_array($this->searchFields[$table] ?? null)
266  || !in_array($field, $this->searchFields[$table], true)
267  ) {
268  $this->searchFields[$table][] = $field;
269  }
270  }
271  }
272  $rootLineHidden = $this->pagesRepository->doesRootLineContainHiddenPages($this->pageRecord);
273  if (!$rootLineHidden || ($this->modTS['checkhidden'] ?? false)) {
274  $this->linkAnalyzer->init($this->searchFields, $this->‪getPageList(), $this->modTS);
275  }
276  }
277 
281  protected function ‪updateBrokenLinks(): void
282  {
283  // convert ['external' => 1, 'db' => 0, ...] into ['external']
284  $linkTypes = [];
285  foreach ($this->checkOpt['check'] ?? [] as $linkType => $value) {
286  if ($value) {
287  $linkTypes[] = $linkType;
288  }
289  }
290  $this->linkAnalyzer->getLinkStatistics($linkTypes, (bool)($this->modTS['checkhidden'] ?? false));
291  }
292 
296  protected function ‪getBrokenLinks(): array
297  {
298  $items = [];
299  $linkTypes = [];
300  if (is_array($this->checkOpt['report'])) {
301  $linkTypes = array_keys($this->checkOpt['report'], '1');
302  }
303  $rootLineHidden = $this->pagesRepository->doesRootLineContainHiddenPages($this->pageRecord);
304  if (!empty($linkTypes) && (!$rootLineHidden || ($this->modTS['checkhidden'] ?? false))) {
305  $brokenLinks = $this->brokenLinkRepository->getAllBrokenLinksForPages(
306  $this->‪getPageList(),
307  $linkTypes,
308  $this->searchFields
309  );
310  foreach ($brokenLinks as $row) {
311  $items[] = $this->‪generateTableRow($row);
312  }
313  }
314  if (empty($items)) {
316  }
317  return $items;
318  }
319 
326  protected function ‪getPageList(): array
327  {
328  $backendUser = $this->‪getBackendUser();
329  $checkForHiddenPages = (bool)($this->modTS['checkhidden'] ?? false);
330  $permsClause = $backendUser->getPagePermsClause(‪Permission::PAGE_SHOW);
331  $pageList = $this->pagesRepository->getAllSubpagesForPage(
332  $this->id,
333  (int)$this->searchLevel['report'],
334  $permsClause,
335  $checkForHiddenPages
336  );
337  // Always add the current page, because we are just displaying the results
338  $pageList[] = ‪$this->id;
339  $pageTranslations = $this->pagesRepository->getTranslationForPage(
340  $this->id,
341  $permsClause,
342  $checkForHiddenPages
343  );
344  return array_merge($pageList, $pageTranslations);
345  }
346 
350  protected function ‪createFlashMessagesForNoBrokenLinks(): void
351  {
352  $languageService = $this->‪getLanguageService();
353  $message = GeneralUtility::makeInstance(
354  FlashMessage::class,
355  $languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:list.no.broken.links'),
356  $languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:list.no.broken.links.title'),
357  ContextualFeedbackSeverity::OK,
358  false
359  );
360  $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class);
361  $defaultFlashMessageQueue = $flashMessageService->getMessageQueueByIdentifier('linkvalidator');
362  $defaultFlashMessageQueue->enqueue($message);
363  }
364 
368  protected function ‪generateTableRow(array $row): array
369  {
370  $fieldLabel = $row['field'];
371  $table = $row['table_name'];
372  $languageService = $this->‪getLanguageService();
373  $hookObj = $this->linktypeRegistry->getLinktype($row['link_type'] ?? '');
374 
375  // Try to resolve the field label from TCA
376  if (‪$GLOBALS['TCA'][$table]['columns'][$row['field']]['label'] ?? false) {
377  $fieldLabel = $languageService->sL(‪$GLOBALS['TCA'][$table]['columns'][$row['field']]['label']);
378  // Crop colon from end if present
379  if (str_ends_with($fieldLabel, ':')) {
380  $fieldLabel = substr($fieldLabel, 0, -1);
381  }
382  }
383 
384  return [
385  'title' => $table . ':' . $row['record_uid'],
386  'icon' => $this->iconFactory->getIconForRecord($table, $row, IconSize::SMALL)->render(),
387  'headline' => $row['headline'],
388  'label' => sprintf($languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:list.field'), $fieldLabel),
389  'path' => BackendUtility::getRecordPath($row['record_pid'], $this->‪getBackendUser()->getPagePermsClause(‪Permission::PAGE_SHOW), 0),
390  'linkTitle' => $row['link_title'],
391  'linkTarget' => $hookObj?->getBrokenUrl($row),
392  'linkStatus' => (bool)($row['url_response']['valid'] ?? false),
393  'linkMessage' => $hookObj?->getErrorMessage($row['url_response']['errorParams']),
394  'lastCheck' => sprintf(
395  $languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:list.msg.lastRun'),
396  date(‪$GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'], $row['last_check']),
397  date(‪$GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'], $row['last_check'])
398  ),
399  'needsRecheck' => (bool)$row['needs_recheck'],
400  // Construct link to edit the record
401  'editUrl' => (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
402  'edit' => [
403  $table => [
404  $row['record_uid'] => 'edit',
405  ],
406  ],
407  'columnsOnly' => $row['field'],
408  'returnUrl' => $this->‪getModuleUri(
409  'report',
410  [
411  'last_edited_record_uid' => $row['record_uid'],
412  'last_edited_record_table' => $table,
413  'last_edited_record_field' => $row['field'],
414  'last_edited_record_timestamp' => $this->context->getPropertyFromAspect('date', 'timestamp'),
415  ]
416  ),
417  ]),
418  ];
419  }
420 
426  protected function ‪getCheckOptions(string $prefix): array
427  {
428  $brokenLinksInformation = $this->linkAnalyzer->getLinkCounts();
429  $options = [
430  'anyOptionChecked' => false,
431  'totalCountLabel' => $this->‪getLanguageService()->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:overviews.nbtotal'),
432  'totalCount' => $brokenLinksInformation['total'] ?: '0',
433  'optionsByType' => [],
434  ];
435  $linkTypes = ‪GeneralUtility::trimExplode(',', $this->modTS['linktypes'] ?? '', true);
436  foreach ($this->linktypeRegistry->getIdentifiers() as $type) {
437  if (!in_array($type, $linkTypes, true)) {
438  continue;
439  }
440  $isChecked = !empty($this->checkOpt[$prefix][$type]);
441  if ($isChecked) {
442  $options['anyOptionChecked'] = true;
443  }
444  $options['optionsByType'][$type] = [
445  'id' => $prefix . '_SET_' . $type,
446  'name' => $prefix . '_SET[' . $type . ']',
447  'label' => $this->‪getLanguageService()->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:hooks.' . $type) ?: $type,
448  'checked' => $isChecked,
449  'count' => (!empty($brokenLinksInformation[$type]) ? $brokenLinksInformation[$type] : '0'),
450  ];
451  }
452  $options['allOptionsChecked'] = array_filter($options['optionsByType'], static fn(array $option): bool => !$option['checked']) === [];
453  return $options;
454  }
455 
456  protected function ‪addDocHeaderShortCutButton(‪ModuleTemplate $view, string $action): void
457  {
458  $buttonBar = $view->‪getDocHeaderComponent()->getButtonBar();
459  $shortcutButton = $buttonBar->makeShortcutButton()
460  ->setRouteIdentifier('web_linkvalidator')
461  ->setDisplayName($this->‪getModuleTitle())
462  ->setArguments(['id' => $this->id, 'action' => $action]);
463  $buttonBar->addButton($shortcutButton);
464  }
465 
466  protected function ‪addDocHeaderDropDown(‪ModuleTemplate $view, string $currentAction): void
467  {
468  $languageService = $this->‪getLanguageService();
469  $actionMenu = $view->‪getDocHeaderComponent()->getMenuRegistry()->makeMenu();
470  $actionMenu->setIdentifier('reportLinkvalidatorSelector');
471  $actionMenu->setLabel(
472  $languageService->sL(
473  'LLL:EXT:backend/Resources/Private/Language/locallang.xlf:moduleMenu.dropdown.label'
474  )
475  );
476  $actionMenu->addMenuItem(
477  $actionMenu->makeMenuItem()
478  ->setTitle($languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:Report'))
479  ->setHref($this->getModuleUri('report'))
480  ->setActive($currentAction === 'report')
481  );
482  $actionMenu->addMenuItem(
483  $actionMenu->makeMenuItem()
484  ->setTitle($languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang.xlf:CheckLink'))
485  ->setHref($this->getModuleUri('check'))
486  ->setActive($currentAction === 'check')
487  );
488  $view->‪getDocHeaderComponent()->getMenuRegistry()->addMenu($actionMenu);
489  }
490 
491  protected function ‪getModuleUri(string $action = null, array $additionalPramaters = []): string
492  {
493  $parameters = [
494  'id' => ‪$this->id,
495  ];
496  if ($action !== null) {
497  $parameters['action'] = $action;
498  }
499  return (string)$this->uriBuilder->buildUriFromRoute('web_linkvalidator', array_replace($parameters, $additionalPramaters));
500  }
501 
502  protected function ‪getModuleTitle(): string
503  {
504  $languageService = $this->‪getLanguageService();
505  $pageTitle = '';
506  $moduleName = $languageService->sL('LLL:EXT:linkvalidator/Resources/Private/Language/Module/locallang_mod.xlf:mlang_labels_tablabel');
507  if ($this->id === 0) {
508  $pageTitle = ‪$GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'];
509  } elseif ($this->pageRecord !== []) {
510  $pageTitle = BackendUtility::getRecordTitle('pages', $this->pageRecord, false, false);
511  }
512  return $moduleName . ($pageTitle !== '' ? ': ' . $pageTitle : '');
513  }
514 
516  {
517  return ‪$GLOBALS['LANG'];
518  }
519 
521  {
522  return ‪$GLOBALS['BE_USER'];
523  }
524 }
‪TYPO3\CMS\Backend\Template\ModuleTemplateFactory
Definition: ModuleTemplateFactory.php:33
‪TYPO3\CMS\Linkvalidator\Repository\PagesRepository
Definition: PagesRepository.php:33
‪TYPO3\CMS\Core\Imaging\IconFactory
Definition: IconFactory.php:34
‪$fields
‪$fields
Definition: pages.php:5
‪TYPO3\CMS\Core\Context\Context
Definition: Context.php:54
‪TYPO3\CMS\Backend\Template\ModuleTemplate
Definition: ModuleTemplate.php:46
‪TYPO3\CMS\Core\Type\Bitmask\Permission
Definition: Permission.php:26
‪TYPO3\CMS\Core\Type\ContextualFeedbackSeverity
‪ContextualFeedbackSeverity
Definition: ContextualFeedbackSeverity.php:25
‪TYPO3\CMS\Backend\Routing\UriBuilder
Definition: UriBuilder.php:44
‪TYPO3\CMS\Core\Authentication\BackendUserAuthentication
Definition: BackendUserAuthentication.php:62
‪TYPO3\CMS\Core\Type\Bitmask\Permission\PAGE_SHOW
‪const PAGE_SHOW
Definition: Permission.php:35
‪TYPO3\CMS\Backend\Template\ModuleTemplate\getDocHeaderComponent
‪getDocHeaderComponent()
Definition: ModuleTemplate.php:181
‪TYPO3\CMS\Core\Messaging\FlashMessage
Definition: FlashMessage.php:27
‪$GLOBALS
‪$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['adminpanel']['modules']
Definition: ext_localconf.php:25
‪TYPO3\CMS\Backend\Attribute\AsController
Definition: AsController.php:25
‪TYPO3\CMS\Linkvalidator\Controller
Definition: LinkValidatorController.php:18
‪TYPO3\CMS\Core\Localization\LanguageService
Definition: LanguageService.php:46
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:52
‪TYPO3\CMS\Core\Messaging\FlashMessageService
Definition: FlashMessageService.php:27
‪TYPO3\CMS\Linkvalidator\Linktype\LinktypeRegistry
Definition: LinktypeRegistry.php:27
‪TYPO3\CMS\Core\Utility\GeneralUtility\trimExplode
‪static list< string > trimExplode(string $delim, string $string, bool $removeEmptyValues=false, int $limit=0)
Definition: GeneralUtility.php:822