Commit ae10a2b5 authored by Sybille Peters's avatar Sybille Peters 🙋 Committed by Tymoteusz Motylewski
Browse files

[FEATURE] Show broken links only in editable fields

Linkvalidator should show broken links only in the list of broken links,
if current backend user has edit access to the field. This way
the editor will no longer get an error message on trying to
edit records he has no permission to edit.

Whether the editor has access depends on a number of factors.

We check the following:

* The current permissions of the page. For editing the page, the editor
  must have Permission::PAGE_EDIT, for editing content
  Permission::CONTENT_EDIT must be available
* The user has write access to the table. We check if the table
  is in 'tables_modify' for the group(s)
* The user has write access to the field. We check if the field
  is an exclude field. If yes, it must be included in
  'non_exclude_fields' for the group(s).
* The user has write permission for the language of the record
* For all tables with type fields: The type is in list of explicitly
  allowed values for authMode (or not explicitly denied depending on
  the setting).

Resolves: #84214
Releases: master
Change-Id: Iade53d0452e0a5dec98e9d5b7b149d137f170949
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/61786


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Susanne Moog's avatarSusanne Moog <look@susi.dev>
Tested-by: Sybille Peters's avatarSybille Peters <sypets@gmx.de>
Tested-by: Tymoteusz Motylewski's avatarTymoteusz Motylewski <t.motylewski@gmail.com>
Reviewed-by: Susanne Moog's avatarSusanne Moog <look@susi.dev>
Reviewed-by: Sybille Peters's avatarSybille Peters <sypets@gmx.de>
Reviewed-by: Tymoteusz Motylewski's avatarTymoteusz Motylewski <t.motylewski@gmail.com>
parent a8b8b7a4
.. include:: ../../Includes.txt
====================================================================
Feature: #84214 - Add check if fields are editable for Linkvalidator
====================================================================
See :issue:`84214`
Description
===========
Broken links should only be shown in the list of broken links,
if current backend user has edit access to the field. This way
the editor will no longer get an error message on trying to
edit records he has no permission to edit.
Whether the editor has access depends on a number of factors.
We check the following:
* The current permissions of the page. For editing the page, the editor must have Permission::PAGE_EDIT, for editing content Permission::CONTENT_EDIT must be available
* The user has write access to the table. We check if the table
is in 'tables_modify' for the group(s)
* The user has write access to the field. We check if the field
is an exclude field. If yes, it must be included in
'non_exclude_fields' for the group(s).
* The user has write permission for the language of the record
* For tt_content: The CType is in list of explicitly allowed
values for authMode.
Impact
======
* Broken links for fields that are not editable for the current backend
user will no longer be shown.
* Fields were added to the tx_linkvalidator_link table. "Analyze
Database Structure" must be executed.
* After an update to the new version, checking of broken links should
be reinitiated for the entire site. Until this is done, some broken
links may not be displayed for editors in the broken link report.
.. index:: Backend, ext:linkvalidator
......@@ -158,6 +158,12 @@ class LinkAnalyzer
// Re-init selectFields for table
$selectFields = array_merge(['uid', 'pid', $GLOBALS['TCA'][$table]['ctrl']['label']], $fields);
if ($GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? false) {
$selectFields[] = $GLOBALS['TCA'][$table]['ctrl']['languageField'];
}
if ($GLOBALS['TCA'][$table]['ctrl']['type'] ?? false) {
$selectFields[] = $GLOBALS['TCA'][$table]['ctrl']['type'];
}
$result = $queryBuilder->select(...$selectFields)
->from($table)
......@@ -196,6 +202,16 @@ class LinkAnalyzer
$record['link_title'] = $entryValue['link_title'];
$record['field'] = $entryValue['field'];
$record['last_check'] = time();
$typeField = $GLOBALS['TCA'][$table]['ctrl']['type'] ?? false;
if ($entryValue['row'][$typeField] ?? false) {
$record['element_type'] = $entryValue['row'][$typeField];
}
$languageField = $GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? false;
if ($languageField && isset($entryValue['row'][$languageField])) {
$record['language'] = $entryValue['row'][$languageField];
} else {
$record['language'] = -1;
}
$this->recordReference = $entryValue['substr']['recordRef'];
if (!empty($entryValue['pageAndAnchor'] ?? '')) {
// Page with anchor, e.g. 18#1580
......@@ -462,13 +478,7 @@ class LinkAnalyzer
*/
public function getLinkCounts()
{
$groupedResult = $this->brokenLinkRepository->getNumberOfBrokenLinksForRecordsOnPages($this->pids);
$data = [];
foreach ($groupedResult as $linkType => $amount) {
$data[$linkType] = $amount;
$data['brokenlinkCount'] += $amount;
}
return $data;
return $this->brokenLinkRepository->getNumberOfBrokenLinksForRecordsOnPages($this->pids, $this->searchFields);
}
/**
......
......@@ -105,8 +105,8 @@ class InternalLinktype extends AbstractLinktype
$this->responseContent = $this->checkContent($page, $anchor);
}
if (
is_array($this->errorParams['page']) && !$this->responsePage
|| is_array($this->errorParams['content']) && !$this->responseContent
(is_array($this->errorParams['page']) && !$this->responsePage)
|| (is_array($this->errorParams['content']) && !$this->responseContent)
) {
$this->setErrorParams($this->errorParams);
}
......
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Linkvalidator\QueryRestrictions;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression;
use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionInterface;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
class EditableRestriction implements QueryRestrictionInterface
{
/**
* Specify which database fields the current user is allowed to edit
*
* @var array
*/
protected $allowedFields = [];
/**
* Specify which languages the current user is allowed to edit
*
* @var array
*/
protected $allowedLanguages = [];
/**
* Explicit allow fields
*
* @var array
*/
protected $explicitAllowFields = [];
/**
* @var QueryBuilder
*/
protected $queryBuilder;
/**
* @param array $searchFields array of 'table' => 'field1, field2'
* in which linkvalidator searches for broken links.
* @param QueryBuilder $queryBuilder
*/
public function __construct(array $searchFields, QueryBuilder $queryBuilder)
{
$this->allowedFields = $this->getAllowedFieldsForCurrentUser($searchFields);
$this->allowedLanguages = $this->getAllowedLanguagesForCurrentUser();
foreach ($searchFields as $table => $fields) {
if ($table !== 'pages' && ($GLOBALS['TCA'][$table]['ctrl']['type'] ?? false)) {
$type = $GLOBALS['TCA'][$table]['ctrl']['type'];
$this->explicitAllowFields[$table][$type] = $this->getExplicitAllowFieldsForCurrentUser($table, $type);
}
}
$this->queryBuilder = $queryBuilder;
}
/**
* Gets all allowed language ids for current backend user
*
* @return array
*/
protected function getAllowedLanguagesForCurrentUser(): array
{
if (!(is_string($GLOBALS['BE_USER']->groupData['allowed_languages'] ?? false))) {
return [];
}
return array_map('intval', explode(',', $GLOBALS['BE_USER']->groupData['allowed_languages']));
}
protected function getExplicitAllowFieldsForCurrentUser(string $table, string $field): array
{
$allowDenyOptions = [];
$fieldConfig = $GLOBALS['TCA'][$table]['columns'][$field]['config'];
// Check for items
if ($fieldConfig['type'] === 'select' && is_array($fieldConfig['items'] ?? false)) {
foreach ($fieldConfig['items'] as $iVal) {
$itemIdentifier = (string)$iVal[1];
if ($GLOBALS['BE_USER']->checkAuthMode($table, $field, $itemIdentifier, $GLOBALS['TYPO3_CONF_VARS']['BE']['explicitADmode'])) {
$allowDenyOptions[] = $itemIdentifier;
}
}
}
return $allowDenyOptions;
}
/**
* Get allowed table / fieldnames for current backend user.
* Only consider table / fields in $searchFields
*
* @param array $searchFields array of 'table' => ['field1, field2', ....]
* in which linkvalidator searches for broken links
* @return array
*/
protected function getAllowedFieldsForCurrentUser(array $searchFields = []): array
{
if (!$searchFields) {
return [];
}
$allowedFields = [];
foreach ($searchFields as $table => $fieldList) {
if (!$GLOBALS['BE_USER']->isAdmin() && !$GLOBALS['BE_USER']->check('tables_modify', $table)) {
// table not allowed
continue;
}
foreach ($fieldList as $field) {
$isExcludeField = $GLOBALS['TCA'][$table]['columns'][$field]['exclude'] ?? false;
if (!$GLOBALS['BE_USER']->isAdmin()
&& $isExcludeField
&& !$GLOBALS['BE_USER']->check('non_exclude_fields', $table . ':' . $field)) {
continue;
}
$allowedFields[$table][$field] = true;
}
}
return $allowedFields;
}
public function buildExpression(array $queriedTables, ExpressionBuilder $expressionBuilder): CompositeExpression
{
$constraints = [];
if ($this->allowedFields) {
$constraints = [
$expressionBuilder->orX(
// broken link is in page and page is editable
$expressionBuilder->andX(
$expressionBuilder->eq(
'tx_linkvalidator_link.table_name',
$this->queryBuilder->createNamedParameter('pages')
),
QueryHelper::stripLogicalOperatorPrefix($GLOBALS['BE_USER']->getPagePermsClause(Permission::PAGE_EDIT))
),
// OR broken link is in content and content is editable
$expressionBuilder->andX(
$expressionBuilder->neq(
'tx_linkvalidator_link.table_name',
$this->queryBuilder->createNamedParameter('pages')
),
QueryHelper::stripLogicalOperatorPrefix($GLOBALS['BE_USER']->getPagePermsClause(Permission::CONTENT_EDIT))
)
)
];
// check if fields are editable
$additionalWhere = [];
foreach ($this->allowedFields as $table => $fields) {
foreach ($fields as $field => $value) {
$additionalWhere[] = $expressionBuilder->andX(
$expressionBuilder->eq(
'tx_linkvalidator_link.table_name',
$this->queryBuilder->createNamedParameter($table)
),
$expressionBuilder->eq(
'tx_linkvalidator_link.field',
$this->queryBuilder->createNamedParameter($field)
)
);
}
}
if ($additionalWhere) {
$constraints[] = $expressionBuilder->orX(...$additionalWhere);
}
} else {
// add a constraint that will always return zero records because there are NO allowed fields
$constraints[] = $expressionBuilder->isNull('tx_linkvalidator_link.table_name');
}
foreach ($this->explicitAllowFields as $table => $field) {
$additionalWhere = [];
$additionalWhere[] = $expressionBuilder->andX(
$expressionBuilder->eq(
'tx_linkvalidator_link.table_name',
$this->queryBuilder->createNamedParameter($table)
),
$expressionBuilder->in(
'tx_linkvalidator_link.element_type',
$this->queryBuilder->createNamedParameter(
array_unique(current($field)),
Connection::PARAM_STR_ARRAY
)
)
);
$additionalWhere[] = $expressionBuilder->neq(
'tx_linkvalidator_link.table_name',
$this->queryBuilder->createNamedParameter($table)
);
if ($additionalWhere) {
$constraints[] = $expressionBuilder->orX(...$additionalWhere);
}
}
if ($this->allowedLanguages) {
$additionalWhere = [];
foreach ($this->allowedLanguages as $langId) {
$additionalWhere[] = $expressionBuilder->orX(
$expressionBuilder->eq(
'tx_linkvalidator_link.language',
$this->queryBuilder->createNamedParameter($langId, \PDO::PARAM_INT)
),
$expressionBuilder->eq(
'tx_linkvalidator_link.language',
$this->queryBuilder->createNamedParameter(-1, \PDO::PARAM_INT)
)
);
}
$constraints[] = $expressionBuilder->orX(...$additionalWhere);
}
// If allowed languages is empty: all languages are allowed, so no constraint in this case
return $expressionBuilder->andX(...$constraints);
}
}
......@@ -143,6 +143,11 @@ class LinkValidatorReport
*/
protected $view;
public function __construct()
{
$this->brokenLinkRepository = GeneralUtility::makeInstance(BrokenLinkRepository::class);
}
/**
* Init, called from parent object
*
......@@ -262,8 +267,8 @@ class LinkValidatorReport
} else {
// mark broken links for last edited record as needing a recheck
$this->brokenLinkRepository->setNeedsRecheckForRecord(
$this->lastEditedRecord['table'],
(int)$this->lastEditedRecord['uid']
(int)$this->lastEditedRecord['uid'],
$this->lastEditedRecord['table']
);
}
}
......@@ -324,7 +329,7 @@ class LinkValidatorReport
}
$this->pageRecord = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
if ($this->id && is_array($this->pageRecord) || !$this->id && $this->getBackendUser()->isAdmin()) {
if (($this->id && is_array($this->pageRecord)) || (!$this->id && $this->getBackendUser()->isAdmin())) {
$this->isAccessibleForCurrentUser = true;
}
// Don't access in workspace
......@@ -402,7 +407,11 @@ class LinkValidatorReport
$items = [];
$rootLineHidden = $this->linkAnalyzer->getRootLineIsHidden($this->pObj->pageinfo);
if (!$rootLineHidden || (bool)$this->modTS['checkhidden'] && !empty($linkTypes)) {
$brokenLinks = $this->brokenLinkRepository->getAllBrokenLinksForPages($this->getPageList(), $linkTypes);
$brokenLinks = $this->brokenLinkRepository->getAllBrokenLinksForPages(
$this->getPageList(),
$linkTypes,
$this->searchFields
);
foreach ($brokenLinks as $row) {
$items[] = $this->renderTableRow($row['table_name'], $row);
}
......@@ -570,7 +579,7 @@ class LinkValidatorReport
{
$variables = [];
$variables['totalCountLabel'] = BackendUtility::wrapInHelp('linkvalidator', 'checkboxes', $this->getLanguageService()->getLL('overviews.nbtotal'));
$variables['totalCount'] = $brokenLinkOverView['brokenlinkCount'] ?: '0';
$variables['totalCount'] = $brokenLinkOverView['total'] ?: '0';
$variables['optionsByType'] = [];
$linkTypes = GeneralUtility::trimExplode(',', $this->modTS['linktypes'] ?? '', true);
$availableLinkTypes = array_keys($this->hookObjectsArr);
......
......@@ -19,6 +19,7 @@ namespace TYPO3\CMS\Linkvalidator\Repository;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Linkvalidator\QueryRestrictions\EditableRestriction;
/**
* Repository for finding broken links that were detected previously.
......@@ -90,17 +91,27 @@ class BrokenLinkRepository
* grouped by the link_type.
*
* @param array $pageIds
* @param array $searchFields [ table => [field1, field2, ...], ...]
* @return array
*/
public function getNumberOfBrokenLinksForRecordsOnPages(array $pageIds): array
public function getNumberOfBrokenLinksForRecordsOnPages(array $pageIds, array $searchFields): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable(static::TABLE);
$queryBuilder->getRestrictions()->removeAll();
if (!$GLOBALS['BE_USER']->isAdmin()) {
$queryBuilder->getRestrictions()
->add(GeneralUtility::makeInstance(EditableRestriction::class, $searchFields, $queryBuilder));
}
$statement = $queryBuilder->select('link_type')
->addSelectLiteral($queryBuilder->expr()->count('uid', 'amount'))
->addSelectLiteral($queryBuilder->expr()->count(static::TABLE . '.uid', 'amount'))
->from(static::TABLE)
->join(
static::TABLE,
'pages',
'pages',
$queryBuilder->expr()->eq('record_pid', $queryBuilder->quoteIdentifier('pages.uid'))
)
->where(
$queryBuilder->expr()->orX(
$queryBuilder->expr()->andX(
......@@ -122,9 +133,12 @@ class BrokenLinkRepository
->groupBy('link_type')
->execute();
$result = [];
$result = [
'total' => 0
];
while ($row = $statement->fetch()) {
$result[$row['link_type']] = $row['amount'];
$result['total']+= $row['amount'];
}
return $result;
}
......@@ -205,17 +219,31 @@ class BrokenLinkRepository
/**
* Prepare database query with pageList and keyOpt data.
*
* This takes permissions of current BE user into account
*
* @param int[] $pageIds Pages to check for broken links
* @param string[] $linkTypes Link types to validate
* @param string[] $searchFields table => [fields1, field2, ...], ... : fields in which linkvalidator should
* search for broken links
* @return array
*/
public function getAllBrokenLinksForPages(array $pageIds, array $linkTypes): array
public function getAllBrokenLinksForPages(array $pageIds, array $linkTypes, array $searchFields = []): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable(self::TABLE);
if (!$GLOBALS['BE_USER']->isAdmin()) {
$queryBuilder->getRestrictions()
->add(GeneralUtility::makeInstance(EditableRestriction::class, $searchFields, $queryBuilder));
}
$records = $queryBuilder
->select('*')
->select(self::TABLE . '.*')
->from(self::TABLE)
->join(
'tx_linkvalidator_link',
'pages',
'pages',
$queryBuilder->expr()->eq('tx_linkvalidator_link.record_pid', $queryBuilder->quoteIdentifier('pages.uid'))
)
->where(
$queryBuilder->expr()->orX(
$queryBuilder->expr()->andX(
......@@ -238,13 +266,13 @@ class BrokenLinkRepository
$queryBuilder->createNamedParameter($linkTypes, Connection::PARAM_STR_ARRAY)
)
)
->orderBy('record_uid')
->addOrderBy('uid')
->orderBy('tx_linkvalidator_link.record_uid')
->addOrderBy('tx_linkvalidator_link.uid')
->execute()
->fetchAll();
foreach ($records as &$record) {
$response = json_decode($record['url_response'], true);
// Fallback mechansim to still support the old serialized data, could be removed in TYPO3 v12 or later
// Fallback mechanism to still support the old serialized data, could be removed in TYPO3 v12 or later
if ($response === null) {
$response = unserialize($record['url_response'], ['allowed_classes' => false]);
}
......
......@@ -23,6 +23,7 @@ use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MailUtility;
use TYPO3\CMS\Linkvalidator\LinkAnalyzer;
use TYPO3\CMS\Linkvalidator\Repository\BrokenLinkRepository;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
/**
......@@ -128,6 +129,9 @@ class ValidatorTask extends AbstractTask
*/
protected $languageFile = 'LLL:EXT:linkvalidator/Resources/Private/Language/locallang.xlf';
/** @var BrokenLinkRepository */
protected $brokenLinkRepository;
/**
* Get the value of the protected property email
*
......@@ -256,6 +260,7 @@ class ValidatorTask extends AbstractTask
*/
public function execute()
{
$this->brokenLinkRepository = GeneralUtility::makeInstance(BrokenLinkRepository::class);
$this->setCliArguments();
$this->templateService = GeneralUtility::makeInstance(MarkerBasedTemplateService::class);
$successfullyExecuted = true;
......@@ -343,12 +348,12 @@ class ValidatorTask extends AbstractTask
$processor->init($searchFields, $pageIds, $modTs);
if (!empty($this->email)) {
$oldLinkCounts = $processor->getLinkCounts();
$this->oldTotalBrokenLink += $oldLinkCounts['brokenlinkCount'];
$this->oldTotalBrokenLink += $oldLinkCounts['total'];
}
$processor->getLinkStatistics($linkTypes, $modTs['checkhidden']);
if (!empty($this->email)) {
$linkCounts = $processor->getLinkCounts();
$this->totalBrokenLink += $linkCounts['brokenlinkCount'];
$this->totalBrokenLink += $linkCounts['total'];
$pageSections = $this->buildMail($page, $pageIds, $linkCounts, $oldLinkCounts);
}
}
......@@ -547,7 +552,7 @@ class ValidatorTask extends AbstractTask
BackendUtility::getRecord('pages', $curPage)
);
$content = '';
if ($markerArray['brokenlinkCount'] > 0) {
if ($markerArray['total'] > 0) {
$content = $this->templateService->substituteMarkerArray(
$pageSectionHtml,
$markerArray,
......
<?xml version="1.0" encoding="utf-8"?>
<dataset>
<be_groups>
<uid>1</uid>
<pid>0</pid>
<tstamp>1366642540</tstamp>
<tables_modify>pages,tt_content</tables_modify>
<explicit_allowdeny>tt_content:CType:text:ALLOW,tt_content:CType:textmedia:ALLOW</explicit_allowdeny>
</be_groups>
<be_groups>
<uid>2</uid>
<pid>0</pid>
<tstamp>1366642540</tstamp>
<tables_modify>pages,tt_content</tables_modify>
<non_exclude_fields>tt_content:header_link</non_exclude_fields>
<explicit_allowdeny>tt_content:CType:text:ALLOW,tt_content:CType:textmedia:ALLOW</explicit_allowdeny>
</be_groups>
<be_groups>
<uid>3</uid>
<pid>0</pid>
<tstamp>1366642540</tstamp>
<tables_modify>pages,tt_content</tables_modify>
<allowed_languages>0</allowed_languages>
<explicit_allowdeny>tt_content:CType:text:ALLOW,tt_content:CType:textmedia:ALLOW</explicit_allowdeny>
</be_groups>
<!-- group 6: editors with access to all, but only CType=textmedia and text via explicit allow -->
<be_groups>
<uid>6</uid>
<pid>0</pid>