wiki.techinc.nl/includes/Storage/EditResultBuilder.php

344 lines
9.8 KiB
PHP
Raw Normal View History

<?php
/**
* Builder class for the EditResult object.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
*
* @author Ostrzyciel
*/
namespace MediaWiki\Storage;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use Wikimedia\Assert\Assert;
/**
* Builder class for the EditResult object.
*
* @internal Only for use by PageUpdater
* @since 1.35
*/
class EditResultBuilder {
Add mw-reverted change tag The tag is added to reverted edits as described in T254074. Functionality: * Adding the mw-reverted tag to reverted edits (duh) * Limiting the maximum depth of the update through a config variable (mitigation #2 from T259014). * Only applying the reverted tag after the edit has been somehow approved. Only the patrol subsystem currently implements this, but there's a hook that extensions can use (mitigation #4 from T259014, more explanation in T259103). * When performing the delayed update, it is checked whether the reverted edit was reverted itself. If so, the update is ignored. This is probably the only way to make the feature work due to the lack of an explicit "disapproval" mechanism other than reverting. * The update is also ignored if the revert is marked as deleted. Technical design: * The update code is in RevertedTagUpdate.php, which is a deferrable update, but is not used as such. It's separated to allow for better DI, testing and better code reusability in the future. * The update is queued / ran using the Job subsystem. The relevant job is in RevertedTagUpdateJob.php * PageUpdater determines whether the edit is approved or not and passes that to the DerivedPageDataUpdater. * The BeforeRevertedTagUpdate hook lets extensions decide whether the update should be ran right away or await approval. * DerivedPageDataUpdater checks whether the edit is a revert and if so either enqueues the job (if it's auto-approved) or caches the EditResult for later use (if it needs approval). * RevertedTagUpdateManager allows for easy re-enqueueing of the update for extensions. Thus, it has a very minimal interface. Other notes: * The unit testing setup for RevertedTagUpdate is a bit complicated, but it was the only way I could make this class testable while using the static ChangeTags class. Bug: T254074 Depends-On: I86d0e660f0acd51a7351396c5c82a400d3963b94 Change-Id: I70d5b29fec6b6058613f7ac2fb49f9fad9dc8da4
2020-07-06 11:47:22 +00:00
public const CONSTRUCTOR_OPTIONS = [
MainConfigNames::ManualRevertSearchRadius,
Add mw-reverted change tag The tag is added to reverted edits as described in T254074. Functionality: * Adding the mw-reverted tag to reverted edits (duh) * Limiting the maximum depth of the update through a config variable (mitigation #2 from T259014). * Only applying the reverted tag after the edit has been somehow approved. Only the patrol subsystem currently implements this, but there's a hook that extensions can use (mitigation #4 from T259014, more explanation in T259103). * When performing the delayed update, it is checked whether the reverted edit was reverted itself. If so, the update is ignored. This is probably the only way to make the feature work due to the lack of an explicit "disapproval" mechanism other than reverting. * The update is also ignored if the revert is marked as deleted. Technical design: * The update code is in RevertedTagUpdate.php, which is a deferrable update, but is not used as such. It's separated to allow for better DI, testing and better code reusability in the future. * The update is queued / ran using the Job subsystem. The relevant job is in RevertedTagUpdateJob.php * PageUpdater determines whether the edit is approved or not and passes that to the DerivedPageDataUpdater. * The BeforeRevertedTagUpdate hook lets extensions decide whether the update should be ran right away or await approval. * DerivedPageDataUpdater checks whether the edit is a revert and if so either enqueues the job (if it's auto-approved) or caches the EditResult for later use (if it needs approval). * RevertedTagUpdateManager allows for easy re-enqueueing of the update for extensions. Thus, it has a very minimal interface. Other notes: * The unit testing setup for RevertedTagUpdate is a bit complicated, but it was the only way I could make this class testable while using the static ChangeTags class. Bug: T254074 Depends-On: I86d0e660f0acd51a7351396c5c82a400d3963b94 Change-Id: I70d5b29fec6b6058613f7ac2fb49f9fad9dc8da4
2020-07-06 11:47:22 +00:00
];
/**
* A mapping from EditResult's revert methods to relevant change tags.
* For use by getRevertTags()
*/
private const REVERT_METHOD_TO_CHANGE_TAG = [
EditResult::REVERT_UNDO => 'mw-undo',
EditResult::REVERT_ROLLBACK => 'mw-rollback',
EditResult::REVERT_MANUAL => 'mw-manual-revert'
];
/** @var RevisionRecord|null */
private $revisionRecord = null;
/** @var bool */
private $isNew = false;
/** @var int|bool */
private $originalRevisionId = false;
/** @var RevisionRecord|null */
private $originalRevision = null;
/** @var int|null */
private $revertMethod = null;
/** @var int|null */
private $newestRevertedRevId = null;
/** @var int|null */
private $oldestRevertedRevId = null;
/** @var int|null */
private $revertAfterRevId = null;
/** @var RevisionStore */
private $revisionStore;
/** @var string[] */
private $softwareTags;
/** @var ServiceOptions */
private $options;
/**
* @param RevisionStore $revisionStore
* @param string[] $softwareTags Array of currently enabled software change tags. Can be
* obtained from ChangeTags::getSoftwareTags()
* @param ServiceOptions $options Options for this instance.
*/
public function __construct(
RevisionStore $revisionStore,
array $softwareTags,
ServiceOptions $options
) {
Add mw-reverted change tag The tag is added to reverted edits as described in T254074. Functionality: * Adding the mw-reverted tag to reverted edits (duh) * Limiting the maximum depth of the update through a config variable (mitigation #2 from T259014). * Only applying the reverted tag after the edit has been somehow approved. Only the patrol subsystem currently implements this, but there's a hook that extensions can use (mitigation #4 from T259014, more explanation in T259103). * When performing the delayed update, it is checked whether the reverted edit was reverted itself. If so, the update is ignored. This is probably the only way to make the feature work due to the lack of an explicit "disapproval" mechanism other than reverting. * The update is also ignored if the revert is marked as deleted. Technical design: * The update code is in RevertedTagUpdate.php, which is a deferrable update, but is not used as such. It's separated to allow for better DI, testing and better code reusability in the future. * The update is queued / ran using the Job subsystem. The relevant job is in RevertedTagUpdateJob.php * PageUpdater determines whether the edit is approved or not and passes that to the DerivedPageDataUpdater. * The BeforeRevertedTagUpdate hook lets extensions decide whether the update should be ran right away or await approval. * DerivedPageDataUpdater checks whether the edit is a revert and if so either enqueues the job (if it's auto-approved) or caches the EditResult for later use (if it needs approval). * RevertedTagUpdateManager allows for easy re-enqueueing of the update for extensions. Thus, it has a very minimal interface. Other notes: * The unit testing setup for RevertedTagUpdate is a bit complicated, but it was the only way I could make this class testable while using the static ChangeTags class. Bug: T254074 Depends-On: I86d0e660f0acd51a7351396c5c82a400d3963b94 Change-Id: I70d5b29fec6b6058613f7ac2fb49f9fad9dc8da4
2020-07-06 11:47:22 +00:00
$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
$this->revisionStore = $revisionStore;
$this->softwareTags = $softwareTags;
$this->options = $options;
}
/**
* @return EditResult
*/
public function buildEditResult(): EditResult {
if ( $this->revisionRecord === null ) {
throw new PageUpdateException(
'Revision was not set prior to building an EditResult'
);
}
// If we don't know the original revision ID, but know which one was undone, try to find out
$this->guessOriginalRevisionId();
// do a last-minute check if this was a manual revert
$this->detectManualRevert();
return new EditResult(
$this->isNew,
$this->originalRevisionId,
$this->revertMethod,
$this->oldestRevertedRevId,
$this->newestRevertedRevId,
$this->isExactRevert(),
$this->isNullEdit(),
$this->getRevertTags()
);
}
/**
* Set the revision associated with this edit.
* Should only be called by PageUpdater when saving an edit.
*
* @param RevisionRecord $revisionRecord
*/
public function setRevisionRecord( RevisionRecord $revisionRecord ) {
$this->revisionRecord = $revisionRecord;
}
/**
* Set whether the edit created a new page.
* Should only be called by PageUpdater when saving an edit.
*
* @param bool $isNew
*/
public function setIsNew( bool $isNew ) {
$this->isNew = $isNew;
}
/**
* Marks this edit as a revert and applies relevant information.
*
* @param int $revertMethod The method used to make the revert:
* REVERT_UNDO, REVERT_ROLLBACK or REVERT_MANUAL
* @param int $newestRevertedRevId the revision ID of the latest reverted revision.
* @param int|null $revertAfterRevId the revision ID after which revisions
* are being reverted. Defaults to the revision before the $newestRevertedRevId.
*/
public function markAsRevert(
int $revertMethod,
int $newestRevertedRevId,
int $revertAfterRevId = null
) {
Assert::parameter(
in_array(
$revertMethod,
[ EditResult::REVERT_UNDO, EditResult::REVERT_ROLLBACK, EditResult::REVERT_MANUAL ]
),
'$revertMethod',
'must be one of REVERT_UNDO, REVERT_ROLLBACK, REVERT_MANUAL'
);
$this->revertAfterRevId = $revertAfterRevId;
if ( $newestRevertedRevId ) {
$this->revertMethod = $revertMethod;
$this->newestRevertedRevId = $newestRevertedRevId;
$revertAfterRevision = $revertAfterRevId ?
$this->revisionStore->getRevisionById( $revertAfterRevId ) :
null;
$oldestRevertedRev = $revertAfterRevision ?
$this->revisionStore->getNextRevision( $revertAfterRevision ) : null;
if ( $oldestRevertedRev ) {
$this->oldestRevertedRevId = $oldestRevertedRev->getId();
} else {
// Can't find the oldest reverted revision.
// Oh well, just mark the one we know was undone.
$this->oldestRevertedRevId = $this->newestRevertedRevId;
}
}
}
/**
* @param RevisionRecord|int|bool|null $originalRevision
* RevisionRecord or revision ID for the original revision.
* False or null to unset.
*/
public function setOriginalRevision( $originalRevision ) {
if ( $originalRevision instanceof RevisionRecord ) {
$this->originalRevision = $originalRevision;
$this->originalRevisionId = $originalRevision->getId();
} else {
$this->originalRevisionId = $originalRevision ?? false;
$this->originalRevision = null; // Will be lazy-loaded.
}
}
/**
* If this edit was not already marked as a revert using EditResultBuilder::markAsRevert(),
* tries to establish whether this was a manual revert, i.e. someone restored the page to
* an exact previous state manually.
*
* If successful, mutates the builder accordingly.
*/
private function detectManualRevert() {
$searchRadius = $this->options->get( MainConfigNames::ManualRevertSearchRadius );
if ( !$searchRadius ||
// we already marked this as a revert
$this->revertMethod !== null ||
// it's a null edit, nothing was reverted
$this->isNullEdit() ||
// we wouldn't be able to figure out what was the newest reverted edit
// this also discards new pages
!$this->revisionRecord->getParentId()
) {
return;
}
$revertedToRev = $this->revisionStore->findIdenticalRevision( $this->revisionRecord, $searchRadius );
if ( !$revertedToRev ) {
return;
}
$oldestReverted = $this->revisionStore->getNextRevision( $revertedToRev );
if ( !$oldestReverted ) {
return;
}
$this->setOriginalRevision( $revertedToRev );
$this->revertMethod = EditResult::REVERT_MANUAL;
$this->oldestRevertedRevId = $oldestReverted->getId();
$this->newestRevertedRevId = $this->revisionRecord->getParentId();
$this->revertAfterRevId = $revertedToRev->getId();
}
/**
* In case we have not got the original revision ID, try to guess.
*/
private function guessOriginalRevisionId() {
if ( !$this->originalRevisionId ) {
if ( $this->revertAfterRevId ) {
$this->setOriginalRevision( $this->revertAfterRevId );
} elseif ( $this->newestRevertedRevId ) {
// Try finding the original revision ID by assuming it's the one before the edit
// that is being reverted.
$undidRevision = $this->revisionStore->getRevisionById( $this->newestRevertedRevId );
if ( $undidRevision ) {
$originalRevision = $this->revisionStore->getPreviousRevision( $undidRevision );
if ( $originalRevision ) {
$this->setOriginalRevision( $originalRevision );
}
}
}
}
// Make sure original revision's content is the same as
// the new content and save the original revision ID.
if ( $this->getOriginalRevision() &&
!$this->getOriginalRevision()->hasSameContent( $this->revisionRecord )
) {
$this->setOriginalRevision( false );
}
}
/**
* Returns the revision that is being repeated or restored.
* Returns null if not set for this edit.
*
* @return RevisionRecord|null
*/
private function getOriginalRevision(): ?RevisionRecord {
if ( $this->originalRevision ) {
return $this->originalRevision;
}
if ( !$this->originalRevisionId ) {
return null;
}
$this->originalRevision = $this->revisionStore->getRevisionById( $this->originalRevisionId );
return $this->originalRevision;
}
/**
* Whether the edit was an exact revert, i.e. the contents of the revert
* revision and restored revision match
*
* @return bool
*/
private function isExactRevert(): bool {
if ( $this->isNew || $this->oldestRevertedRevId === null ) {
return false;
}
$originalRevision = $this->getOriginalRevision();
if ( !$originalRevision ) {
// we can't find the original revision for some reason, better return false
return false;
}
return $this->revisionRecord->hasSameContent( $originalRevision );
}
/**
* An edit is a null edit if the original revision is equal to the parent revision.
*
* @return bool
*/
private function isNullEdit(): bool {
if ( $this->isNew ) {
return false;
}
return $this->getOriginalRevision() &&
$this->originalRevisionId === $this->revisionRecord->getParentId();
}
/**
* Returns an array of revert-related tags that will be applied automatically to this edit.
*
* @return string[]
*/
private function getRevertTags(): array {
if ( isset( self::REVERT_METHOD_TO_CHANGE_TAG[$this->revertMethod] ) ) {
$revertTag = self::REVERT_METHOD_TO_CHANGE_TAG[$this->revertMethod];
if ( in_array( $revertTag, $this->softwareTags ) ) {
return [ $revertTag ];
}
}
return [];
}
}