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 MediaWiki\ChangeTags\ChangeTags;
24use MediaWiki\ChangeTags\ChangeTagsStore;
25use MediaWiki\Config\ServiceOptions;
26use MediaWiki\Deferred\DeferrableUpdate;
27use MediaWiki\Json\FormatJson;
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    private function shouldExecute(): bool {
171        $maxDepth = $this->options->get( MainConfigNames::RevertedTagMaxDepth );
172        if (
173            !in_array( ChangeTags::TAG_REVERTED, $this->changeTagsStore->getSoftwareTags() ) ||
174            $maxDepth <= 0
175        ) {
176            return false;
177        }
178
179        $extraParams = $this->getTagExtraParams();
180        if ( !$this->editResult->isRevert() ||
181            $this->editResult->getOldestRevertedRevisionId() === null ||
182            $this->editResult->getNewestRevertedRevisionId() === null
183        ) {
184            $this->logger->error( 'Invalid EditResult specified.', $extraParams );
185            return false;
186        }
187
188        if ( !$this->getOldestRevertedRevision() || !$this->getNewestRevertedRevision() ) {
189            $this->logger->error(
190                'Could not find the newest or oldest reverted revision in the database.',
191                $extraParams
192            );
193            return false;
194        }
195        if ( !$this->getRevertRevision() ) {
196            $this->logger->error(
197                'Could not find the revert revision in the database.',
198                $extraParams
199            );
200            return false;
201        }
202
203        if ( $this->getNewestRevertedRevision()->getPageId() !==
204            $this->getOldestRevertedRevision()->getPageId()
205            ||
206            $this->getOldestRevertedRevision()->getPageId() !==
207            $this->getRevertRevision()->getPageId()
208        ) {
209            $this->logger->error(
210                'The revert and reverted revisions belong to different pages.',
211                $extraParams
212            );
213            return false;
214        }
215
216        if ( $this->getRevertRevision()->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
217            // The revert's text is marked as deleted, which probably means the update
218            // shouldn't be executed.
219            $this->logger->info(
220                'The revert\'s text had been marked as deleted before the update was ' .
221                'executed. Skipping...',
222                $extraParams
223            );
224            return false;
225        }
226
227        $changeTagsOnRevert = $this->changeTagsStore->getTags(
228            $this->dbProvider->getReplicaDatabase(),
229            null,
230            $this->revertId
231        );
232        if ( in_array( ChangeTags::TAG_REVERTED, $changeTagsOnRevert ) ) {
233            // This is already marked as reverted, which means the update was delayed
234            // until the edit is approved. Apparently, the edit was not approved, as
235            // it was reverted, so the update should not be performed.
236            $this->logger->info(
237                'The revert had been reverted before the update was executed. Skipping...',
238                $extraParams
239            );
240            return false;
241        }
242
243        return true;
244    }
245
246    /**
247     * Handles the case where only one edit was reverted.
248     * Returns true if the update was handled by this method, false otherwise.
249     *
250     * This is a much simpler case requiring less DB queries than when dealing with multiple
251     * reverted edits.
252     */
253    private function handleSingleRevertedEdit(): bool {
254        if ( $this->editResult->getOldestRevertedRevisionId() !==
255            $this->editResult->getNewestRevertedRevisionId()
256        ) {
257            return false;
258        }
259
260        $revertedRevision = $this->getOldestRevertedRevision();
261        if ( $revertedRevision === null ||
262            $revertedRevision->isDeleted( RevisionRecord::DELETED_TEXT )
263        ) {
264            return true;
265        }
266
267        $previousRevision = $this->revisionStore->getPreviousRevision(
268            $revertedRevision
269        );
270        if ( $previousRevision !== null &&
271            $revertedRevision->hasSameContent( $previousRevision )
272        ) {
273            // Ignore the very rare case of a null edit. This should not occur unless
274            // someone does something weird with the page's history before the update
275            // is executed.
276            return true;
277        }
278        $this->changeTagsStore->addTags(
279            [ ChangeTags::TAG_REVERTED ],
280            null,
281            $this->editResult->getOldestRevertedRevisionId(),
282            null,
283            FormatJson::encode( $this->getTagExtraParams() )
284        );
285        return true;
286    }
287
288    /**
289     * Returns additional data to be saved in ct_params field of table 'change_tag'.
290     *
291     * Effectively a superset of what EditResult::jsonSerialize() returns.
292     */
293    private function getTagExtraParams(): array {
294        return array_merge(
295            [ 'revertId' => $this->revertId ],
296            $this->editResult->jsonSerialize()
297        );
298    }
299
300    /**
301     * Returns the revision that performed the revert.
302     *
303     * @return RevisionRecord|null
304     */
305    private function getRevertRevision(): ?RevisionRecord {
306        if ( !$this->revertRevision ) {
307            $this->revertRevision = $this->revisionStore->getRevisionById(
308                $this->revertId
309            );
310        }
311        return $this->revertRevision;
312    }
313
314    /**
315     * Returns the newest revision record that was reverted.
316     *
317     * @return RevisionRecord|null
318     */
319    private function getNewestRevertedRevision(): ?RevisionRecord {
320        if ( !$this->newestRevertedRevision ) {
321            $this->newestRevertedRevision = $this->revisionStore->getRevisionById(
322                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable newestRevertedRevision is checked
323                $this->editResult->getNewestRevertedRevisionId()
324            );
325        }
326        return $this->newestRevertedRevision;
327    }
328
329    /**
330     * Returns the oldest revision record that was reverted.
331     *
332     * @return RevisionRecord|null
333     */
334    private function getOldestRevertedRevision(): ?RevisionRecord {
335        if ( !$this->oldestRevertedRevision ) {
336            $this->oldestRevertedRevision = $this->revisionStore->getRevisionById(
337                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable oldestRevertedRevision is checked
338                $this->editResult->getOldestRevertedRevisionId()
339            );
340        }
341        return $this->oldestRevertedRevision;
342    }
343}