Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.76% covered (success)
97.76%
131 / 134
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevertedTagUpdate
97.76% covered (success)
97.76%
131 / 134
75.00% covered (warning)
75.00%
6 / 8
35
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 doUpdate
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
8
 shouldExecute
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
13
 handleSingleRevertedEdit
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
6.03
 getTagExtraParams
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getRevertRevision
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getNewestRevertedRevision
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getOldestRevertedRevision
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Storage;
8
9use MediaWiki\ChangeTags\ChangeTags;
10use MediaWiki\ChangeTags\ChangeTagsStore;
11use MediaWiki\Config\ServiceOptions;
12use MediaWiki\Deferred\DeferrableUpdate;
13use MediaWiki\Json\FormatJson;
14use MediaWiki\MainConfigNames;
15use MediaWiki\Revision\RevisionRecord;
16use MediaWiki\Revision\RevisionStore;
17use Psr\Log\LoggerInterface;
18use Wikimedia\Rdbms\IConnectionProvider;
19
20/**
21 * Adds the mw-reverted tag to reverted edits after a revert is made.
22 *
23 * This class is used by RevertedTagUpdateJob to perform the actual update.
24 *
25 * @since 1.36
26 * @author Ostrzyciel
27 */
28class RevertedTagUpdate implements DeferrableUpdate {
29
30    /**
31     * @internal
32     */
33    public const CONSTRUCTOR_OPTIONS = [ MainConfigNames::RevertedTagMaxDepth ];
34
35    /** @var RevisionStore */
36    private $revisionStore;
37
38    /** @var LoggerInterface */
39    private $logger;
40
41    /** @var IConnectionProvider */
42    private $dbProvider;
43
44    /** @var ServiceOptions */
45    private $options;
46
47    /** @var int */
48    private $revertId;
49
50    /** @var EditResult */
51    private $editResult;
52
53    /** @var RevisionRecord|null */
54    private $revertRevision;
55
56    /** @var RevisionRecord|null */
57    private $newestRevertedRevision;
58
59    /** @var RevisionRecord|null */
60    private $oldestRevertedRevision;
61    private ChangeTagsStore $changeTagsStore;
62
63    /**
64     * @param RevisionStore $revisionStore
65     * @param LoggerInterface $logger
66     * @param ChangeTagsStore $changeTagsStore
67     * @param IConnectionProvider $dbProvider
68     * @param ServiceOptions $serviceOptions
69     * @param int $revertId ID of the revert
70     * @param EditResult $editResult EditResult object of this revert
71     */
72    public function __construct(
73        RevisionStore $revisionStore,
74        LoggerInterface $logger,
75        ChangeTagsStore $changeTagsStore,
76        IConnectionProvider $dbProvider,
77        ServiceOptions $serviceOptions,
78        int $revertId,
79        EditResult $editResult
80    ) {
81        $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
82
83        $this->revisionStore = $revisionStore;
84        $this->logger = $logger;
85        $this->dbProvider = $dbProvider;
86        $this->options = $serviceOptions;
87        $this->revertId = $revertId;
88        $this->editResult = $editResult;
89        $this->changeTagsStore = $changeTagsStore;
90    }
91
92    /**
93     * Marks reverted edits with `mw-reverted` tag.
94     */
95    public function doUpdate() {
96        // Do extensive checks, as the update may be carried out even months after the edit
97        if ( !$this->shouldExecute() ) {
98            return;
99        }
100
101        // Skip some of the DB code and just tag it if only one edit was reverted
102        if ( $this->handleSingleRevertedEdit() ) {
103            return;
104        }
105
106        $maxDepth = $this->options->get( MainConfigNames::RevertedTagMaxDepth );
107        $extraParams = $this->getTagExtraParams();
108        $revertedRevisionIds = $this->revisionStore->getRevisionIdsBetween(
109            $this->getOldestRevertedRevision()->getPageId(),
110            $this->getOldestRevertedRevision(),
111            $this->getNewestRevertedRevision(),
112            $maxDepth,
113            RevisionStore::INCLUDE_BOTH
114        );
115
116        if ( count( $revertedRevisionIds ) > $maxDepth ) {
117            // This revert exceeds the depth limit
118            $this->logger->info(
119                'The revert is deeper than $wgRevertedTagMaxDepth. Skipping...',
120                $extraParams
121            );
122            return;
123        }
124
125        $revertedRevision = null;
126        foreach ( $revertedRevisionIds as $revId ) {
127            $previousRevision = $revertedRevision;
128
129            $revertedRevision = $this->revisionStore->getRevisionById( $revId );
130            if ( $revertedRevision === null ) {
131                // Shouldn't happen, but necessary for static analysis
132                continue;
133            }
134
135            $previousRevision ??= $this->revisionStore->getPreviousRevision( $revertedRevision );
136            if ( $previousRevision !== null &&
137                $revertedRevision->hasSameContent( $previousRevision )
138            ) {
139                // This is a dummy revision (e.g. a page move or protection record)
140                // See: T265312
141                continue;
142            }
143            $this->changeTagsStore->addTags(
144                [ ChangeTags::TAG_REVERTED ],
145                null,
146                $revId,
147                null,
148                FormatJson::encode( $extraParams )
149            );
150        }
151    }
152
153    /**
154     * Performs checks to determine whether the update should execute.
155     */
156    private function shouldExecute(): bool {
157        $maxDepth = $this->options->get( MainConfigNames::RevertedTagMaxDepth );
158        if (
159            !in_array( ChangeTags::TAG_REVERTED, $this->changeTagsStore->getSoftwareTags() ) ||
160            $maxDepth <= 0
161        ) {
162            return false;
163        }
164
165        $extraParams = $this->getTagExtraParams();
166        if ( !$this->editResult->isRevert() ||
167            $this->editResult->getOldestRevertedRevisionId() === null ||
168            $this->editResult->getNewestRevertedRevisionId() === null
169        ) {
170            $this->logger->error( 'Invalid EditResult specified.', $extraParams );
171            return false;
172        }
173
174        if ( !$this->getOldestRevertedRevision() || !$this->getNewestRevertedRevision() ) {
175            $this->logger->error(
176                'Could not find the newest or oldest reverted revision in the database.',
177                $extraParams
178            );
179            return false;
180        }
181        if ( !$this->getRevertRevision() ) {
182            $this->logger->error(
183                'Could not find the revert revision in the database.',
184                $extraParams
185            );
186            return false;
187        }
188
189        if ( $this->getNewestRevertedRevision()->getPageId() !==
190            $this->getOldestRevertedRevision()->getPageId()
191            ||
192            $this->getOldestRevertedRevision()->getPageId() !==
193            $this->getRevertRevision()->getPageId()
194        ) {
195            $this->logger->error(
196                'The revert and reverted revisions belong to different pages.',
197                $extraParams
198            );
199            return false;
200        }
201
202        if ( $this->getRevertRevision()->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
203            // The revert's text is marked as deleted, which probably means the update
204            // shouldn't be executed.
205            $this->logger->info(
206                'The revert\'s text had been marked as deleted before the update was ' .
207                'executed. Skipping...',
208                $extraParams
209            );
210            return false;
211        }
212
213        $changeTagsOnRevert = $this->changeTagsStore->getTags(
214            $this->dbProvider->getReplicaDatabase(),
215            null,
216            $this->revertId
217        );
218        if ( in_array( ChangeTags::TAG_REVERTED, $changeTagsOnRevert ) ) {
219            // This is already marked as reverted, which means the update was delayed
220            // until the edit is approved. Apparently, the edit was not approved, as
221            // it was reverted, so the update should not be performed.
222            $this->logger->info(
223                'The revert had been reverted before the update was executed. Skipping...',
224                $extraParams
225            );
226            return false;
227        }
228
229        return true;
230    }
231
232    /**
233     * Handles the case where only one edit was reverted.
234     * Returns true if the update was handled by this method, false otherwise.
235     *
236     * This is a much simpler case requiring less DB queries than when dealing with multiple
237     * reverted edits.
238     */
239    private function handleSingleRevertedEdit(): bool {
240        if ( $this->editResult->getOldestRevertedRevisionId() !==
241            $this->editResult->getNewestRevertedRevisionId()
242        ) {
243            return false;
244        }
245
246        $revertedRevision = $this->getOldestRevertedRevision();
247        if ( $revertedRevision === null ||
248            $revertedRevision->isDeleted( RevisionRecord::DELETED_TEXT )
249        ) {
250            return true;
251        }
252
253        $previousRevision = $this->revisionStore->getPreviousRevision(
254            $revertedRevision
255        );
256        if ( $previousRevision !== null &&
257            $revertedRevision->hasSameContent( $previousRevision )
258        ) {
259            // Ignore the very rare case of a null edit. This should not occur unless
260            // someone does something weird with the page's history before the update
261            // is executed.
262            return true;
263        }
264        $this->changeTagsStore->addTags(
265            [ ChangeTags::TAG_REVERTED ],
266            null,
267            $this->editResult->getOldestRevertedRevisionId(),
268            null,
269            FormatJson::encode( $this->getTagExtraParams() )
270        );
271        return true;
272    }
273
274    /**
275     * Returns additional data to be saved in ct_params field of table 'change_tag'.
276     *
277     * Effectively a superset of what EditResult::jsonSerialize() returns.
278     */
279    private function getTagExtraParams(): array {
280        return array_merge(
281            [ 'revertId' => $this->revertId ],
282            $this->editResult->jsonSerialize()
283        );
284    }
285
286    /**
287     * Returns the revision that performed the revert.
288     *
289     * @return RevisionRecord|null
290     */
291    private function getRevertRevision(): ?RevisionRecord {
292        if ( !$this->revertRevision ) {
293            $this->revertRevision = $this->revisionStore->getRevisionById(
294                $this->revertId
295            );
296        }
297        return $this->revertRevision;
298    }
299
300    /**
301     * Returns the newest revision record that was reverted.
302     *
303     * @return RevisionRecord|null
304     */
305    private function getNewestRevertedRevision(): ?RevisionRecord {
306        if ( !$this->newestRevertedRevision ) {
307            $this->newestRevertedRevision = $this->revisionStore->getRevisionById(
308                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable newestRevertedRevision is checked
309                $this->editResult->getNewestRevertedRevisionId()
310            );
311        }
312        return $this->newestRevertedRevision;
313    }
314
315    /**
316     * Returns the oldest revision record that was reverted.
317     *
318     * @return RevisionRecord|null
319     */
320    private function getOldestRevertedRevision(): ?RevisionRecord {
321        if ( !$this->oldestRevertedRevision ) {
322            $this->oldestRevertedRevision = $this->revisionStore->getRevisionById(
323                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable oldestRevertedRevision is checked
324                $this->editResult->getOldestRevertedRevisionId()
325            );
326        }
327        return $this->oldestRevertedRevision;
328    }
329}