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