Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.74% covered (danger)
15.74%
37 / 235
37.04% covered (danger)
37.04%
10 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
FlaggedRevision
15.74% covered (danger)
15.74%
37 / 235
37.04% covered (danger)
37.04%
10 / 27
2361.19
0.00% covered (danger)
0.00%
0 / 1
 newFromRow
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
3.18
 newFromTitle
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 newFromStable
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
56
 getStableRevId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 determineStable
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 insert
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 delete
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryInfo
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 getRevId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTitle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevisionRecord
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevText
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 getTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 userCanSetTag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStableTemplateVersions
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 findPendingTemplateChanges
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 approveRevertedTagUpdate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 revIsFlagged
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getDefaultTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getDefaultTag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 expandRevisionTags
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 flattenRevisionTags
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3use MediaWiki\Logger\LoggerFactory;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\Revision\RevisionAccessException;
6use MediaWiki\Revision\RevisionRecord;
7use MediaWiki\Revision\SlotRecord;
8use MediaWiki\Title\Title;
9use MediaWiki\User\UserIdentity;
10use Wikimedia\Rdbms\SelectQueryBuilder;
11
12/**
13 * Class representing a stable version of a MediaWiki revision
14 *
15 * This contains a page revision
16 */
17class FlaggedRevision {
18
19    /** @var RevisionRecord base revision */
20    private $mRevRecord;
21
22    /* Flagging metadata */
23    /** @var mixed review timestamp */
24    private $mTimestamp;
25    /** @var array<string,int> Review tags */
26    private array $mTags;
27    /** @var string[] flags (for auto-review ect...) */
28    private $mFlags;
29    /** @var int reviewing user */
30    private $mUser;
31
32    /* Redundant fields for lazy-loading */
33    /** @var Title|null */
34    private $mTitle;
35    /** @var array|null stable versions of template version used */
36    private $mStableTemplates;
37
38    /**
39     * @param stdClass $row DB row
40     * @param Title $title
41     * @param int $flags One of the IDBAccessObject::READ_… constants
42     * @return self
43     */
44    private static function newFromRow( stdClass $row, Title $title, $flags ) {
45        # Base Revision object
46        $revFactory = MediaWikiServices::getInstance()->getRevisionFactory();
47        $revRecord = $revFactory->newRevisionFromRow( $row, $flags, $title );
48        $frev = new self( [
49            'timestamp' => $row->fr_timestamp,
50            'tags' => $row->fr_tags,
51            'flags' => $row->fr_flags,
52            'user_id' => $row->fr_user,
53            'revrecord' => $revRecord,
54        ] );
55        $frev->mTitle = $title;
56        return $frev;
57    }
58
59    /**
60     * @param array $row
61     */
62    public function __construct( array $row ) {
63        if ( !is_array( $row['tags'] ) ) {
64            $row['tags'] = self::expandRevisionTags( $row['tags'] );
65        }
66
67        $this->mTimestamp = $row['timestamp'];
68        $this->mTags = array_merge( self::getDefaultTags(), $row['tags'] );
69        $this->mFlags = explode( ',', $row['flags'] );
70        $this->mUser = intval( $row['user_id'] );
71        # Base Revision object
72        $this->mRevRecord = $row['revrecord'];
73        if ( !( $this->mRevRecord instanceof RevisionRecord ) ) {
74            throw new InvalidArgumentException(
75                'FlaggedRevision constructor passed invalid RevisionRecord object.'
76            );
77        }
78    }
79
80    /**
81     * Get a FlaggedRevision for a title and rev ID.
82     * Note: will return NULL if the revision is deleted.
83     * @param Title $title
84     * @param int $revId
85     * @param int $flags One of the IDBAccessObject::READ_… constants
86     * @return self|null (null on failure)
87     */
88    public static function newFromTitle( Title $title, $revId, $flags = 0 ) {
89        if ( !$revId || !FlaggedRevs::inReviewNamespace( $title ) ) {
90            return null; // short-circuit
91        }
92        # User primary/replica as appropriate...
93        $pageId = $title->getArticleID( $flags );
94        if ( !$pageId ) {
95            return null; // short-circuit query
96        }
97        if ( $flags & IDBAccessObject::READ_LATEST ) {
98            $db = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
99        } else {
100            $db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
101        }
102        # Skip deleted revisions
103        $frQuery = self::getQueryInfo();
104        $row = $db->newSelectQueryBuilder()
105            ->tables( $frQuery['tables'] )
106            ->fields( $frQuery['fields'] )
107            ->where( [
108                'fr_page_id' => $pageId,
109                'fr_rev_id'  => $revId,
110                $db->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . ' = 0'
111            ] )
112            ->joinConds( $frQuery['joins'] )
113            ->caller( __METHOD__ )
114            ->fetchRow();
115        # Sorted from highest to lowest, so just take the first one if any
116        return $row ? self::newFromRow( $row, $title, $flags ) : null;
117    }
118
119    /**
120     * Get a FlaggedRevision of the stable version of a title.
121     * Note: will return NULL if the revision is deleted, though this
122     * should never happen as fp_stable is updated as revs are deleted.
123     * @param Title $title page title
124     * @param int $flags One of the IDBAccessObject::READ_… constants
125     * @return self|null (null on failure)
126     */
127    public static function newFromStable( Title $title, $flags = 0 ) {
128        if ( !FlaggedRevs::inReviewNamespace( $title ) ) {
129            return null; // short-circuit
130        }
131        # User primary/replica as appropriate...
132        $pageId = $title->getArticleID( $flags );
133        if ( !$pageId ) {
134            return null; // short-circuit query
135        }
136        if ( $flags & IDBAccessObject::READ_LATEST ) {
137            $db = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
138        } else {
139            $db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
140        }
141        # Check tracking tables
142        $frQuery = self::getQueryInfo();
143        $row = $db->newSelectQueryBuilder()
144            ->tables( $frQuery['tables'] )
145            ->fields( $frQuery['fields'] )
146            ->select( [ 'fr_page_id' ] )
147            ->join( 'flaggedpages', null, 'fr_rev_id = fp_stable' )
148            ->where( [
149                'fp_page_id' => $pageId,
150                $db->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . ' = 0', // sanity
151            ] )
152            ->joinConds( $frQuery['joins'] )
153            ->caller( __METHOD__ )
154            ->fetchRow();
155        if ( $row ) {
156            if ( (int)$row->rev_page !== $pageId || (int)$row->fr_page_id !== $pageId ) {
157                // Warn about inconsistent flaggedpages rows, see T246720
158                $logger = LoggerFactory::getInstance( 'FlaggedRevisions' );
159                $logger->warning( 'Found revision with mismatching page ID! ', [
160                    'fp_page_id' => $pageId,
161                    'fr_page_id' => $row->fr_page_id,
162                    'rev_page' => $row->rev_page,
163                    'rev_id' => $row->rev_id,
164                    'trace' => wfBacktrace()
165                ] );
166
167                // TODO: Can we make this self-healing somehow? We shouldn't return a FlaggedRevision
168                //       here that belongs to a different page. Can we find the correct revision for
169                //       the given page ID in flaggedrevs? Can we rely on fr_page_id, or is that
170                //       going to be wrong as well?
171                return null;
172            }
173
174            return self::newFromRow( $row, $title, $flags );
175        }
176        return null;
177    }
178
179    /**
180     * Get the ID of the stable version of a title.
181     * @param Title $title page title
182     * @return int (0 on failure)
183     */
184    public static function getStableRevId( Title $title ) {
185        $srev = self::newFromStable( $title );
186        return $srev ? $srev->getRevId() : 0;
187    }
188
189    /**
190     * Get a FlaggedRevision of the stable version of a title.
191     * Skips tracking tables to figure out new stable version.
192     * @param Title $title page title
193     * @return self|null (null on failure)
194     */
195    public static function determineStable( Title $title ) {
196        if ( !FlaggedRevs::inReviewNamespace( $title ) ) {
197            return null; // short-circuit
198        }
199
200        $db = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
201        $pageId = $title->getArticleID( IDBAccessObject::READ_LATEST );
202        if ( !$pageId ) {
203            return null; // short-circuit query
204        }
205        # Get visibility settings to see if page is reviewable...
206        if ( FlaggedRevs::useOnlyIfProtected() ) {
207            $config = FRPageConfig::getStabilitySettings( $title, IDBAccessObject::READ_LATEST );
208            if ( !$config['override'] ) {
209                return null; // page is not reviewable; no stable version
210            }
211        }
212        $baseConds = [
213            'fr_page_id' => $pageId,
214            'rev_id = fr_rev_id',
215            'rev_page = fr_page_id', // sanity
216            $db->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . ' = 0'
217        ];
218
219        $frQuery = self::getQueryInfo();
220        $row = $db->newSelectQueryBuilder()
221            ->tables( $frQuery['tables'] )
222            ->fields( $frQuery['fields'] )
223            ->where( $baseConds )
224            ->orderBy( [ 'fr_rev_timestamp', 'fr_rev_id' ], SelectQueryBuilder::SORT_DESC )
225            ->joinConds( $frQuery['joins'] )
226            ->caller( __METHOD__ )
227            ->fetchRow();
228        return $row ? self::newFromRow( $row, $title, IDBAccessObject::READ_LATEST ) : null;
229    }
230
231    /**
232     * Insert a FlaggedRevision object into the database
233     *
234     * @return true|string true on success, error string on failure
235     */
236    public function insert() {
237        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
238
239        # Set any flagged revision flags
240        $this->mFlags = array_merge( $this->mFlags, [ 'dynamic' ] ); // legacy
241        # Sanity check for partial revisions
242        if ( !$this->getPage() ) {
243            return 'no page id';
244        } elseif ( !$this->getRevId() ) {
245            return 'no revision id';
246        }
247        # Our new review entry
248        $revRow = [
249            'fr_page_id'       => $this->getPage(),
250            'fr_rev_id'        => $this->getRevId(),
251            'fr_rev_timestamp' => $dbw->timestamp( $this->getRevTimestamp() ),
252            'fr_user'          => $this->mUser,
253            'fr_timestamp'     => $dbw->timestamp( $this->mTimestamp ),
254            'fr_quality'       => FR_CHECKED,
255            'fr_tags'          => self::flattenRevisionTags( $this->mTags ),
256            'fr_flags'         => implode( ',', $this->mFlags ),
257        ];
258        # Update the main flagged revisions table...
259        $dbw->newInsertQueryBuilder()->insertInto( 'flaggedrevs' )
260            ->ignore()
261            ->row( $revRow )
262            ->caller( __METHOD__ )
263            ->execute();
264        if ( !$dbw->affectedRows() ) {
265            return 'duplicate review';
266        }
267        return true;
268    }
269
270    /**
271     * Remove a FlaggedRevision object from the database
272     */
273    public function delete() {
274        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
275
276        # Delete from flaggedrevs table
277        $dbw->newDeleteQueryBuilder()
278            ->deleteFrom( 'flaggedrevs' )
279            ->where( [ 'fr_rev_id' => $this->getRevId() ] )
280            ->caller( __METHOD__ )
281            ->execute();
282    }
283
284    /**
285     * Get query info for FlaggedRevision DB row (flaggedrevs/revision tables)
286     * @return array
287     */
288    private static function getQueryInfo() {
289        $revQuery = MediaWikiServices::getInstance()->getRevisionStore()->getQueryInfo();
290        return [
291            'tables' => array_merge( [ 'flaggedrevs' ], $revQuery['tables'] ),
292            'fields' => array_merge( $revQuery['fields'], [
293                'fr_rev_id', 'fr_page_id', 'fr_rev_timestamp',
294                'fr_user', 'fr_timestamp', 'fr_tags', 'fr_flags'
295            ] ),
296            'joins' => [
297                'revision' => [ 'JOIN', [
298                    'rev_id = fr_rev_id',
299                    'rev_page = fr_page_id', // sanity
300                ] ],
301            ] + $revQuery['joins'],
302        ];
303    }
304
305    /**
306     * @return int revision record's ID
307     */
308    public function getRevId() {
309        return $this->mRevRecord->getId();
310    }
311
312    /**
313     * @return int page ID
314     */
315    private function getPage() {
316        return $this->mRevRecord->getPageId();
317    }
318
319    /**
320     * @return Title
321     */
322    public function getTitle() {
323        if ( $this->mTitle === null ) {
324            $linkTarget = $this->mRevRecord->getPageAsLinkTarget();
325            $this->mTitle = Title::newFromLinkTarget( $linkTarget );
326        }
327        return $this->mTitle;
328    }
329
330    /**
331     * Get timestamp of review
332     * @return string revision timestamp in MW format
333     */
334    public function getTimestamp() {
335        return wfTimestamp( TS_MW, $this->mTimestamp );
336    }
337
338    /**
339     * Get timestamp of the corresponding revision
340     * Note: here for convenience
341     * @return string revision timestamp in MW format
342     */
343    public function getRevTimestamp() {
344        return $this->mRevRecord->getTimestamp();
345    }
346
347    /**
348     * Get the corresponding revision record
349     * @return RevisionRecord
350     */
351    public function getRevisionRecord() {
352        return $this->mRevRecord;
353    }
354
355    /**
356     * Get text of the corresponding revision
357     * Note: here for convenience
358     * @return string|null Revision text, if available
359     */
360    public function getRevText() {
361        try {
362            $content = $this->mRevRecord->getContent( SlotRecord::MAIN );
363        } catch ( RevisionAccessException $e ) {
364            return '';
365        }
366        return ( $content instanceof TextContent ) ? $content->getText() : null;
367    }
368
369    /**
370     * Get tags (levels) of all tiers this revision has.
371     * Use getTag() instead unless you really need other tiers set on
372     * historical revisions (these tiers are no longer supported, cannot
373     * be set by users anymore).
374     * @return array<string,int> tag metadata
375     */
376    public function getTags(): array {
377        return $this->mTags;
378    }
379
380    /**
381     * Get the tag (level) of the page in the default tier.
382     * This is always defined (possibly zero) unless in protection mode.
383     */
384    public function getTag(): ?int {
385        return $this->mTags[FlaggedRevs::getTagName()] ?? null;
386    }
387
388    /**
389     * Whether the given user can set the tag in the default tier.
390     * Always returns true in protection mode if the user has review right.
391     */
392    public function userCanSetTag( UserIdentity $user ): bool {
393        return FlaggedRevs::userCanSetTag( $user, $this->getTag() );
394    }
395
396    /**
397     * Get the current stable version of the templates
398     * @return int[][] template versions (ns -> dbKey -> rev Id)
399     * Note: 0 used for template rev Id if it doesn't exist
400     */
401    public function getStableTemplateVersions() {
402        if ( $this->mStableTemplates == null ) {
403            $this->mStableTemplates = [];
404            $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
405
406            $linksMigration = MediaWikiServices::getInstance()->getLinksMigration();
407            [ $nsField, $titleField ] = $linksMigration->getTitleFields( 'templatelinks' );
408            $queryInfo = $linksMigration->getQueryInfo( 'templatelinks' );
409            $res = $dbr->newSelectQueryBuilder()
410                ->select( [ 'page_namespace', 'page_title', 'fp_stable' ] )
411                ->tables( $queryInfo['tables'] )
412                ->leftJoin( 'page', null, [ "page_namespace = $nsField", "page_title = $titleField" ] )
413                ->leftJoin( 'flaggedpages', null, 'fp_page_id = page_id' )
414                ->where( [
415                    'tl_from' => $this->getPage(),
416                    # Only get templates with stable or "review time" versions.
417                    $dbr->expr( 'fp_stable', '!=', null ),
418                ] ) // current version templates
419                ->joinConds( $queryInfo['joins'] )
420                ->caller( __METHOD__ )
421                ->fetchResultSet();
422            foreach ( $res as $row ) {
423                $revId = (int)$row->fp_stable; // 0 => none
424                $this->mStableTemplates[$row->page_namespace][$row->page_title] = $revId;
425            }
426        }
427        return $this->mStableTemplates;
428    }
429
430    /**
431     * Fetch pending template changes for this reviewed page version.
432     * For each template, the "version used" (for stable parsing) is:
433     *    (a) (the latest rev) if FR_INCLUDES_CURRENT. Might be non-existing.
434     *    (b) newest( stable rev, rev at time of review ) if FR_INCLUDES_STABLE
435     *
436     * @return bool
437     */
438    public function findPendingTemplateChanges() {
439        if ( FlaggedRevs::inclusionSetting() == FR_INCLUDES_CURRENT ) {
440            return false; // short-circuit
441        }
442        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
443
444        $linksMigration = MediaWikiServices::getInstance()->getLinksMigration();
445        [ $nsField, $titleField ] = $linksMigration->getTitleFields( 'templatelinks' );
446        $queryInfo = $linksMigration->getQueryInfo( 'templatelinks' );
447        $ret = $dbr->newSelectQueryBuilder()
448            ->select( [ $nsField, $titleField ] )
449            ->tables( $queryInfo['tables'] )
450            ->leftJoin( 'page', null, [ "page_namespace = $nsField", "page_title = $titleField" ] )
451            ->join( 'flaggedpages', null, 'fp_page_id = page_id' )
452            ->where( [
453                'tl_from' => $this->getPage(),
454                # Only get templates with stable or "review time" versions.
455                $dbr->expr( 'fp_pending_since', '!=', null )->or( 'fp_stable', '=', null ),
456            ] ) // current version templates
457            ->joinConds( $queryInfo['joins'] )
458            ->caller( __METHOD__ )
459            ->fetchResultSet();
460        return (bool)$ret->count();
461    }
462
463    /**
464     * Notify the reverted tag subsystem that the edit was reviewed.
465     */
466    public function approveRevertedTagUpdate() {
467        $rtuManager = MediaWikiServices::getInstance()->getRevertedTagUpdateManager();
468        $rtuManager->approveRevertedTagForRevision( $this->getRevId() );
469    }
470
471    /**
472     * @param int $rev_id
473     * @param int $flags One of the IDBAccessObject::READ_… constants
474     * @return bool
475     */
476    public static function revIsFlagged( int $rev_id, int $flags = 0 ): bool {
477        if ( $flags & IDBAccessObject::READ_LATEST ) {
478            $db = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
479        } else {
480            $db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
481        }
482        return (bool)$db->newSelectQueryBuilder()
483            ->select( '1' )
484            ->from( 'flaggedrevs' )
485            ->where( [ 'fr_rev_id' => $rev_id ] )
486            ->caller( __METHOD__ )
487            ->fetchField();
488    }
489
490    /**
491     * @return array<string,int>
492     */
493    public static function getDefaultTags(): array {
494        return FlaggedRevs::useOnlyIfProtected() ? [] : [ FlaggedRevs::getTagName() => 0 ];
495    }
496
497    public static function getDefaultTag(): ?int {
498        return FlaggedRevs::useOnlyIfProtected() ? null : 0;
499    }
500
501    /**
502     * @param string $tags
503     * @return array<string,int>
504     */
505    public static function expandRevisionTags( string $tags ): array {
506        $flags = [];
507        $max = FlaggedRevs::getMaxLevel();
508        $tags = str_replace( '\n', "\n", $tags ); // B/C, old broken rows
509        // Tag string format is <tag:val\ntag:val\n...>
510        $tags = explode( "\n", $tags );
511        foreach ( $tags as $tuple ) {
512            $set = explode( ':', $tuple, 2 );
513            // Skip broken and old serializations that end with \n, which shows up as [ "" ] here
514            if ( count( $set ) == 2 ) {
515                [ $tag, $value ] = $set;
516                $flags[$tag] = min( max( 0, (int)$value ), $max );
517            }
518        }
519        return $flags;
520    }
521
522    /**
523     * @param array<string,int> $tags
524     * @return string
525     */
526    public static function flattenRevisionTags( array $tags ): string {
527        $flags = '';
528        foreach ( $tags as $tag => $value ) {
529            if ( $flags ) {
530                $flags .= "\n";
531            }
532            $flags .= $tag . ':' . (int)$value;
533        }
534        return $flags;
535    }
536}