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