]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - includes/Revision.php
MediaWiki 1.30.2-scripts
[autoinstalls/mediawiki.git] / includes / Revision.php
1 <?php
2 /**
3  * Representation of a page version.
4  *
5  * This program is free software; you can redistribute it and/or modify
6  * it under the terms of the GNU General Public License as published by
7  * the Free Software Foundation; either version 2 of the License, or
8  * (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful,
11  * but WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13  * GNU General Public License for more details.
14  *
15  * You should have received a copy of the GNU General Public License along
16  * with this program; if not, write to the Free Software Foundation, Inc.,
17  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18  * http://www.gnu.org/copyleft/gpl.html
19  *
20  * @file
21  */
22
23 use Wikimedia\Rdbms\Database;
24 use Wikimedia\Rdbms\IDatabase;
25 use MediaWiki\Linker\LinkTarget;
26 use MediaWiki\MediaWikiServices;
27 use Wikimedia\Rdbms\ResultWrapper;
28 use Wikimedia\Rdbms\FakeResultWrapper;
29
30 /**
31  * @todo document
32  */
33 class Revision implements IDBAccessObject {
34         /** @var int|null */
35         protected $mId;
36         /** @var int|null */
37         protected $mPage;
38         /** @var string */
39         protected $mUserText;
40         /** @var string */
41         protected $mOrigUserText;
42         /** @var int */
43         protected $mUser;
44         /** @var bool */
45         protected $mMinorEdit;
46         /** @var string */
47         protected $mTimestamp;
48         /** @var int */
49         protected $mDeleted;
50         /** @var int */
51         protected $mSize;
52         /** @var string */
53         protected $mSha1;
54         /** @var int */
55         protected $mParentId;
56         /** @var string */
57         protected $mComment;
58         /** @var string */
59         protected $mText;
60         /** @var int */
61         protected $mTextId;
62         /** @var int */
63         protected $mUnpatrolled;
64
65         /** @var stdClass|null */
66         protected $mTextRow;
67
68         /**  @var null|Title */
69         protected $mTitle;
70         /** @var bool */
71         protected $mCurrent;
72         /** @var string */
73         protected $mContentModel;
74         /** @var string */
75         protected $mContentFormat;
76
77         /** @var Content|null|bool */
78         protected $mContent;
79         /** @var null|ContentHandler */
80         protected $mContentHandler;
81
82         /** @var int */
83         protected $mQueryFlags = 0;
84         /** @var bool Used for cached values to reload user text and rev_deleted */
85         protected $mRefreshMutableFields = false;
86         /** @var string Wiki ID; false means the current wiki */
87         protected $mWiki = false;
88
89         // Revision deletion constants
90         const DELETED_TEXT = 1;
91         const DELETED_COMMENT = 2;
92         const DELETED_USER = 4;
93         const DELETED_RESTRICTED = 8;
94         const SUPPRESSED_USER = 12; // convenience
95         const SUPPRESSED_ALL = 15; // convenience
96
97         // Audience options for accessors
98         const FOR_PUBLIC = 1;
99         const FOR_THIS_USER = 2;
100         const RAW = 3;
101
102         const TEXT_CACHE_GROUP = 'revisiontext:10'; // process cache name and max key count
103
104         /**
105          * Load a page revision from a given revision ID number.
106          * Returns null if no such revision can be found.
107          *
108          * $flags include:
109          *      Revision::READ_LATEST  : Select the data from the master
110          *      Revision::READ_LOCKING : Select & lock the data from the master
111          *
112          * @param int $id
113          * @param int $flags (optional)
114          * @return Revision|null
115          */
116         public static function newFromId( $id, $flags = 0 ) {
117                 return self::newFromConds( [ 'rev_id' => intval( $id ) ], $flags );
118         }
119
120         /**
121          * Load either the current, or a specified, revision
122          * that's attached to a given link target. If not attached
123          * to that link target, will return null.
124          *
125          * $flags include:
126          *      Revision::READ_LATEST  : Select the data from the master
127          *      Revision::READ_LOCKING : Select & lock the data from the master
128          *
129          * @param LinkTarget $linkTarget
130          * @param int $id (optional)
131          * @param int $flags Bitfield (optional)
132          * @return Revision|null
133          */
134         public static function newFromTitle( LinkTarget $linkTarget, $id = 0, $flags = 0 ) {
135                 $conds = [
136                         'page_namespace' => $linkTarget->getNamespace(),
137                         'page_title' => $linkTarget->getDBkey()
138                 ];
139                 if ( $id ) {
140                         // Use the specified ID
141                         $conds['rev_id'] = $id;
142                         return self::newFromConds( $conds, $flags );
143                 } else {
144                         // Use a join to get the latest revision
145                         $conds[] = 'rev_id=page_latest';
146                         $db = wfGetDB( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA );
147                         return self::loadFromConds( $db, $conds, $flags );
148                 }
149         }
150
151         /**
152          * Load either the current, or a specified, revision
153          * that's attached to a given page ID.
154          * Returns null if no such revision can be found.
155          *
156          * $flags include:
157          *      Revision::READ_LATEST  : Select the data from the master (since 1.20)
158          *      Revision::READ_LOCKING : Select & lock the data from the master
159          *
160          * @param int $pageId
161          * @param int $revId (optional)
162          * @param int $flags Bitfield (optional)
163          * @return Revision|null
164          */
165         public static function newFromPageId( $pageId, $revId = 0, $flags = 0 ) {
166                 $conds = [ 'page_id' => $pageId ];
167                 if ( $revId ) {
168                         $conds['rev_id'] = $revId;
169                         return self::newFromConds( $conds, $flags );
170                 } else {
171                         // Use a join to get the latest revision
172                         $conds[] = 'rev_id = page_latest';
173                         $db = wfGetDB( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA );
174                         return self::loadFromConds( $db, $conds, $flags );
175                 }
176         }
177
178         /**
179          * Make a fake revision object from an archive table row. This is queried
180          * for permissions or even inserted (as in Special:Undelete)
181          * @todo FIXME: Should be a subclass for RevisionDelete. [TS]
182          *
183          * @param object $row
184          * @param array $overrides
185          *
186          * @throws MWException
187          * @return Revision
188          */
189         public static function newFromArchiveRow( $row, $overrides = [] ) {
190                 global $wgContentHandlerUseDB;
191
192                 $attribs = $overrides + [
193                         'page'       => isset( $row->ar_page_id ) ? $row->ar_page_id : null,
194                         'id'         => isset( $row->ar_rev_id ) ? $row->ar_rev_id : null,
195                         'comment'    => CommentStore::newKey( 'ar_comment' )
196                                 // Legacy because $row probably came from self::selectArchiveFields()
197                                 ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row, true )->text,
198                         'user'       => $row->ar_user,
199                         'user_text'  => $row->ar_user_text,
200                         'timestamp'  => $row->ar_timestamp,
201                         'minor_edit' => $row->ar_minor_edit,
202                         'text_id'    => isset( $row->ar_text_id ) ? $row->ar_text_id : null,
203                         'deleted'    => $row->ar_deleted,
204                         'len'        => $row->ar_len,
205                         'sha1'       => isset( $row->ar_sha1 ) ? $row->ar_sha1 : null,
206                         'content_model'   => isset( $row->ar_content_model ) ? $row->ar_content_model : null,
207                         'content_format'  => isset( $row->ar_content_format ) ? $row->ar_content_format : null,
208                 ];
209
210                 if ( !$wgContentHandlerUseDB ) {
211                         unset( $attribs['content_model'] );
212                         unset( $attribs['content_format'] );
213                 }
214
215                 if ( !isset( $attribs['title'] )
216                         && isset( $row->ar_namespace )
217                         && isset( $row->ar_title )
218                 ) {
219                         $attribs['title'] = Title::makeTitle( $row->ar_namespace, $row->ar_title );
220                 }
221
222                 if ( isset( $row->ar_text ) && !$row->ar_text_id ) {
223                         // Pre-1.5 ar_text row
224                         $attribs['text'] = self::getRevisionText( $row, 'ar_' );
225                         if ( $attribs['text'] === false ) {
226                                 throw new MWException( 'Unable to load text from archive row (possibly T24624)' );
227                         }
228                 }
229                 return new self( $attribs );
230         }
231
232         /**
233          * @since 1.19
234          *
235          * @param object $row
236          * @return Revision
237          */
238         public static function newFromRow( $row ) {
239                 return new self( $row );
240         }
241
242         /**
243          * Load a page revision from a given revision ID number.
244          * Returns null if no such revision can be found.
245          *
246          * @param IDatabase $db
247          * @param int $id
248          * @return Revision|null
249          */
250         public static function loadFromId( $db, $id ) {
251                 return self::loadFromConds( $db, [ 'rev_id' => intval( $id ) ] );
252         }
253
254         /**
255          * Load either the current, or a specified, revision
256          * that's attached to a given page. If not attached
257          * to that page, will return null.
258          *
259          * @param IDatabase $db
260          * @param int $pageid
261          * @param int $id
262          * @return Revision|null
263          */
264         public static function loadFromPageId( $db, $pageid, $id = 0 ) {
265                 $conds = [ 'rev_page' => intval( $pageid ), 'page_id' => intval( $pageid ) ];
266                 if ( $id ) {
267                         $conds['rev_id'] = intval( $id );
268                 } else {
269                         $conds[] = 'rev_id=page_latest';
270                 }
271                 return self::loadFromConds( $db, $conds );
272         }
273
274         /**
275          * Load either the current, or a specified, revision
276          * that's attached to a given page. If not attached
277          * to that page, will return null.
278          *
279          * @param IDatabase $db
280          * @param Title $title
281          * @param int $id
282          * @return Revision|null
283          */
284         public static function loadFromTitle( $db, $title, $id = 0 ) {
285                 if ( $id ) {
286                         $matchId = intval( $id );
287                 } else {
288                         $matchId = 'page_latest';
289                 }
290                 return self::loadFromConds( $db,
291                         [
292                                 "rev_id=$matchId",
293                                 'page_namespace' => $title->getNamespace(),
294                                 'page_title' => $title->getDBkey()
295                         ]
296                 );
297         }
298
299         /**
300          * Load the revision for the given title with the given timestamp.
301          * WARNING: Timestamps may in some circumstances not be unique,
302          * so this isn't the best key to use.
303          *
304          * @param IDatabase $db
305          * @param Title $title
306          * @param string $timestamp
307          * @return Revision|null
308          */
309         public static function loadFromTimestamp( $db, $title, $timestamp ) {
310                 return self::loadFromConds( $db,
311                         [
312                                 'rev_timestamp' => $db->timestamp( $timestamp ),
313                                 'page_namespace' => $title->getNamespace(),
314                                 'page_title' => $title->getDBkey()
315                         ]
316                 );
317         }
318
319         /**
320          * Given a set of conditions, fetch a revision
321          *
322          * This method is used then a revision ID is qualified and
323          * will incorporate some basic replica DB/master fallback logic
324          *
325          * @param array $conditions
326          * @param int $flags (optional)
327          * @return Revision|null
328          */
329         private static function newFromConds( $conditions, $flags = 0 ) {
330                 $db = wfGetDB( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA );
331
332                 $rev = self::loadFromConds( $db, $conditions, $flags );
333                 // Make sure new pending/committed revision are visibile later on
334                 // within web requests to certain avoid bugs like T93866 and T94407.
335                 if ( !$rev
336                         && !( $flags & self::READ_LATEST )
337                         && wfGetLB()->getServerCount() > 1
338                         && wfGetLB()->hasOrMadeRecentMasterChanges()
339                 ) {
340                         $flags = self::READ_LATEST;
341                         $db = wfGetDB( DB_MASTER );
342                         $rev = self::loadFromConds( $db, $conditions, $flags );
343                 }
344
345                 if ( $rev ) {
346                         $rev->mQueryFlags = $flags;
347                 }
348
349                 return $rev;
350         }
351
352         /**
353          * Given a set of conditions, fetch a revision from
354          * the given database connection.
355          *
356          * @param IDatabase $db
357          * @param array $conditions
358          * @param int $flags (optional)
359          * @return Revision|null
360          */
361         private static function loadFromConds( $db, $conditions, $flags = 0 ) {
362                 $row = self::fetchFromConds( $db, $conditions, $flags );
363                 if ( $row ) {
364                         $rev = new Revision( $row );
365                         $rev->mWiki = $db->getDomainID();
366
367                         return $rev;
368                 }
369
370                 return null;
371         }
372
373         /**
374          * Return a wrapper for a series of database rows to
375          * fetch all of a given page's revisions in turn.
376          * Each row can be fed to the constructor to get objects.
377          *
378          * @param LinkTarget $title
379          * @return ResultWrapper
380          * @deprecated Since 1.28
381          */
382         public static function fetchRevision( LinkTarget $title ) {
383                 $row = self::fetchFromConds(
384                         wfGetDB( DB_REPLICA ),
385                         [
386                                 'rev_id=page_latest',
387                                 'page_namespace' => $title->getNamespace(),
388                                 'page_title' => $title->getDBkey()
389                         ]
390                 );
391
392                 return new FakeResultWrapper( $row ? [ $row ] : [] );
393         }
394
395         /**
396          * Given a set of conditions, return a ResultWrapper
397          * which will return matching database rows with the
398          * fields necessary to build Revision objects.
399          *
400          * @param IDatabase $db
401          * @param array $conditions
402          * @param int $flags (optional)
403          * @return stdClass
404          */
405         private static function fetchFromConds( $db, $conditions, $flags = 0 ) {
406                 $fields = array_merge(
407                         self::selectFields(),
408                         self::selectPageFields(),
409                         self::selectUserFields()
410                 );
411                 $options = [];
412                 if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) {
413                         $options[] = 'FOR UPDATE';
414                 }
415                 return $db->selectRow(
416                         [ 'revision', 'page', 'user' ],
417                         $fields,
418                         $conditions,
419                         __METHOD__,
420                         $options,
421                         [ 'page' => self::pageJoinCond(), 'user' => self::userJoinCond() ]
422                 );
423         }
424
425         /**
426          * Return the value of a select() JOIN conds array for the user table.
427          * This will get user table rows for logged-in users.
428          * @since 1.19
429          * @return array
430          */
431         public static function userJoinCond() {
432                 return [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ];
433         }
434
435         /**
436          * Return the value of a select() page conds array for the page table.
437          * This will assure that the revision(s) are not orphaned from live pages.
438          * @since 1.19
439          * @return array
440          */
441         public static function pageJoinCond() {
442                 return [ 'INNER JOIN', [ 'page_id = rev_page' ] ];
443         }
444
445         /**
446          * Return the list of revision fields that should be selected to create
447          * a new revision.
448          * @todo Deprecate this in favor of a method that returns tables and joins
449          *  as well, and use CommentStore::getJoin().
450          * @return array
451          */
452         public static function selectFields() {
453                 global $wgContentHandlerUseDB;
454
455                 $fields = [
456                         'rev_id',
457                         'rev_page',
458                         'rev_text_id',
459                         'rev_timestamp',
460                         'rev_user_text',
461                         'rev_user',
462                         'rev_minor_edit',
463                         'rev_deleted',
464                         'rev_len',
465                         'rev_parent_id',
466                         'rev_sha1',
467                 ];
468
469                 $fields += CommentStore::newKey( 'rev_comment' )->getFields();
470
471                 if ( $wgContentHandlerUseDB ) {
472                         $fields[] = 'rev_content_format';
473                         $fields[] = 'rev_content_model';
474                 }
475
476                 return $fields;
477         }
478
479         /**
480          * Return the list of revision fields that should be selected to create
481          * a new revision from an archive row.
482          * @todo Deprecate this in favor of a method that returns tables and joins
483          *  as well, and use CommentStore::getJoin().
484          * @return array
485          */
486         public static function selectArchiveFields() {
487                 global $wgContentHandlerUseDB;
488                 $fields = [
489                         'ar_id',
490                         'ar_page_id',
491                         'ar_rev_id',
492                         'ar_text',
493                         'ar_text_id',
494                         'ar_timestamp',
495                         'ar_user_text',
496                         'ar_user',
497                         'ar_minor_edit',
498                         'ar_deleted',
499                         'ar_len',
500                         'ar_parent_id',
501                         'ar_sha1',
502                 ];
503
504                 $fields += CommentStore::newKey( 'ar_comment' )->getFields();
505
506                 if ( $wgContentHandlerUseDB ) {
507                         $fields[] = 'ar_content_format';
508                         $fields[] = 'ar_content_model';
509                 }
510                 return $fields;
511         }
512
513         /**
514          * Return the list of text fields that should be selected to read the
515          * revision text
516          * @return array
517          */
518         public static function selectTextFields() {
519                 return [
520                         'old_text',
521                         'old_flags'
522                 ];
523         }
524
525         /**
526          * Return the list of page fields that should be selected from page table
527          * @return array
528          */
529         public static function selectPageFields() {
530                 return [
531                         'page_namespace',
532                         'page_title',
533                         'page_id',
534                         'page_latest',
535                         'page_is_redirect',
536                         'page_len',
537                 ];
538         }
539
540         /**
541          * Return the list of user fields that should be selected from user table
542          * @return array
543          */
544         public static function selectUserFields() {
545                 return [ 'user_name' ];
546         }
547
548         /**
549          * Do a batched query to get the parent revision lengths
550          * @param IDatabase $db
551          * @param array $revIds
552          * @return array
553          */
554         public static function getParentLengths( $db, array $revIds ) {
555                 $revLens = [];
556                 if ( !$revIds ) {
557                         return $revLens; // empty
558                 }
559                 $res = $db->select( 'revision',
560                         [ 'rev_id', 'rev_len' ],
561                         [ 'rev_id' => $revIds ],
562                         __METHOD__ );
563                 foreach ( $res as $row ) {
564                         $revLens[$row->rev_id] = $row->rev_len;
565                 }
566                 return $revLens;
567         }
568
569         /**
570          * @param object|array $row Either a database row or an array
571          * @throws MWException
572          * @access private
573          */
574         function __construct( $row ) {
575                 if ( is_object( $row ) ) {
576                         $this->mId = intval( $row->rev_id );
577                         $this->mPage = intval( $row->rev_page );
578                         $this->mTextId = intval( $row->rev_text_id );
579                         $this->mComment = CommentStore::newKey( 'rev_comment' )
580                                 // Legacy because $row probably came from self::selectFields()
581                                 ->getCommentLegacy( wfGetDB( DB_REPLICA ), $row, true )->text;
582                         $this->mUser = intval( $row->rev_user );
583                         $this->mMinorEdit = intval( $row->rev_minor_edit );
584                         $this->mTimestamp = $row->rev_timestamp;
585                         $this->mDeleted = intval( $row->rev_deleted );
586
587                         if ( !isset( $row->rev_parent_id ) ) {
588                                 $this->mParentId = null;
589                         } else {
590                                 $this->mParentId = intval( $row->rev_parent_id );
591                         }
592
593                         if ( !isset( $row->rev_len ) ) {
594                                 $this->mSize = null;
595                         } else {
596                                 $this->mSize = intval( $row->rev_len );
597                         }
598
599                         if ( !isset( $row->rev_sha1 ) ) {
600                                 $this->mSha1 = null;
601                         } else {
602                                 $this->mSha1 = $row->rev_sha1;
603                         }
604
605                         if ( isset( $row->page_latest ) ) {
606                                 $this->mCurrent = ( $row->rev_id == $row->page_latest );
607                                 $this->mTitle = Title::newFromRow( $row );
608                         } else {
609                                 $this->mCurrent = false;
610                                 $this->mTitle = null;
611                         }
612
613                         if ( !isset( $row->rev_content_model ) ) {
614                                 $this->mContentModel = null; # determine on demand if needed
615                         } else {
616                                 $this->mContentModel = strval( $row->rev_content_model );
617                         }
618
619                         if ( !isset( $row->rev_content_format ) ) {
620                                 $this->mContentFormat = null; # determine on demand if needed
621                         } else {
622                                 $this->mContentFormat = strval( $row->rev_content_format );
623                         }
624
625                         // Lazy extraction...
626                         $this->mText = null;
627                         if ( isset( $row->old_text ) ) {
628                                 $this->mTextRow = $row;
629                         } else {
630                                 // 'text' table row entry will be lazy-loaded
631                                 $this->mTextRow = null;
632                         }
633
634                         // Use user_name for users and rev_user_text for IPs...
635                         $this->mUserText = null; // lazy load if left null
636                         if ( $this->mUser == 0 ) {
637                                 $this->mUserText = $row->rev_user_text; // IP user
638                         } elseif ( isset( $row->user_name ) ) {
639                                 $this->mUserText = $row->user_name; // logged-in user
640                         }
641                         $this->mOrigUserText = $row->rev_user_text;
642                 } elseif ( is_array( $row ) ) {
643                         // Build a new revision to be saved...
644                         global $wgUser; // ugh
645
646                         # if we have a content object, use it to set the model and type
647                         if ( !empty( $row['content'] ) ) {
648                                 // @todo when is that set? test with external store setup! check out insertOn() [dk]
649                                 if ( !empty( $row['text_id'] ) ) {
650                                         throw new MWException( "Text already stored in external store (id {$row['text_id']}), " .
651                                                 "can't serialize content object" );
652                                 }
653
654                                 $row['content_model'] = $row['content']->getModel();
655                                 # note: mContentFormat is initializes later accordingly
656                                 # note: content is serialized later in this method!
657                                 # also set text to null?
658                         }
659
660                         $this->mId = isset( $row['id'] ) ? intval( $row['id'] ) : null;
661                         $this->mPage = isset( $row['page'] ) ? intval( $row['page'] ) : null;
662                         $this->mTextId = isset( $row['text_id'] ) ? intval( $row['text_id'] ) : null;
663                         $this->mUserText = isset( $row['user_text'] )
664                                 ? strval( $row['user_text'] ) : $wgUser->getName();
665                         $this->mUser = isset( $row['user'] ) ? intval( $row['user'] ) : $wgUser->getId();
666                         $this->mMinorEdit = isset( $row['minor_edit'] ) ? intval( $row['minor_edit'] ) : 0;
667                         $this->mTimestamp = isset( $row['timestamp'] )
668                                 ? strval( $row['timestamp'] ) : wfTimestampNow();
669                         $this->mDeleted = isset( $row['deleted'] ) ? intval( $row['deleted'] ) : 0;
670                         $this->mSize = isset( $row['len'] ) ? intval( $row['len'] ) : null;
671                         $this->mParentId = isset( $row['parent_id'] ) ? intval( $row['parent_id'] ) : null;
672                         $this->mSha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null;
673
674                         $this->mContentModel = isset( $row['content_model'] )
675                                 ? strval( $row['content_model'] ) : null;
676                         $this->mContentFormat = isset( $row['content_format'] )
677                                 ? strval( $row['content_format'] ) : null;
678
679                         // Enforce spacing trimming on supplied text
680                         $this->mComment = isset( $row['comment'] ) ? trim( strval( $row['comment'] ) ) : null;
681                         $this->mText = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null;
682                         $this->mTextRow = null;
683
684                         $this->mTitle = isset( $row['title'] ) ? $row['title'] : null;
685
686                         // if we have a Content object, override mText and mContentModel
687                         if ( !empty( $row['content'] ) ) {
688                                 if ( !( $row['content'] instanceof Content ) ) {
689                                         throw new MWException( '`content` field must contain a Content object.' );
690                                 }
691
692                                 $handler = $this->getContentHandler();
693                                 $this->mContent = $row['content'];
694
695                                 $this->mContentModel = $this->mContent->getModel();
696                                 $this->mContentHandler = null;
697
698                                 $this->mText = $handler->serializeContent( $row['content'], $this->getContentFormat() );
699                         } elseif ( $this->mText !== null ) {
700                                 $handler = $this->getContentHandler();
701                                 $this->mContent = $handler->unserializeContent( $this->mText );
702                         }
703
704                         // If we have a Title object, make sure it is consistent with mPage.
705                         if ( $this->mTitle && $this->mTitle->exists() ) {
706                                 if ( $this->mPage === null ) {
707                                         // if the page ID wasn't known, set it now
708                                         $this->mPage = $this->mTitle->getArticleID();
709                                 } elseif ( $this->mTitle->getArticleID() !== $this->mPage ) {
710                                         // Got different page IDs. This may be legit (e.g. during undeletion),
711                                         // but it seems worth mentioning it in the log.
712                                         wfDebug( "Page ID " . $this->mPage . " mismatches the ID " .
713                                                 $this->mTitle->getArticleID() . " provided by the Title object." );
714                                 }
715                         }
716
717                         $this->mCurrent = false;
718
719                         // If we still have no length, see it we have the text to figure it out
720                         if ( !$this->mSize && $this->mContent !== null ) {
721                                 $this->mSize = $this->mContent->getSize();
722                         }
723
724                         // Same for sha1
725                         if ( $this->mSha1 === null ) {
726                                 $this->mSha1 = $this->mText === null ? null : self::base36Sha1( $this->mText );
727                         }
728
729                         // force lazy init
730                         $this->getContentModel();
731                         $this->getContentFormat();
732                 } else {
733                         throw new MWException( 'Revision constructor passed invalid row format.' );
734                 }
735                 $this->mUnpatrolled = null;
736         }
737
738         /**
739          * Get revision ID
740          *
741          * @return int|null
742          */
743         public function getId() {
744                 return $this->mId;
745         }
746
747         /**
748          * Set the revision ID
749          *
750          * This should only be used for proposed revisions that turn out to be null edits
751          *
752          * @since 1.19
753          * @param int $id
754          */
755         public function setId( $id ) {
756                 $this->mId = (int)$id;
757         }
758
759         /**
760          * Set the user ID/name
761          *
762          * This should only be used for proposed revisions that turn out to be null edits
763          *
764          * @since 1.28
765          * @param int $id User ID
766          * @param string $name User name
767          */
768         public function setUserIdAndName( $id, $name ) {
769                 $this->mUser = (int)$id;
770                 $this->mUserText = $name;
771                 $this->mOrigUserText = $name;
772         }
773
774         /**
775          * Get text row ID
776          *
777          * @return int|null
778          */
779         public function getTextId() {
780                 return $this->mTextId;
781         }
782
783         /**
784          * Get parent revision ID (the original previous page revision)
785          *
786          * @return int|null
787          */
788         public function getParentId() {
789                 return $this->mParentId;
790         }
791
792         /**
793          * Returns the length of the text in this revision, or null if unknown.
794          *
795          * @return int|null
796          */
797         public function getSize() {
798                 return $this->mSize;
799         }
800
801         /**
802          * Returns the base36 sha1 of the text in this revision, or null if unknown.
803          *
804          * @return string|null
805          */
806         public function getSha1() {
807                 return $this->mSha1;
808         }
809
810         /**
811          * Returns the title of the page associated with this entry or null.
812          *
813          * Will do a query, when title is not set and id is given.
814          *
815          * @return Title|null
816          */
817         public function getTitle() {
818                 if ( $this->mTitle !== null ) {
819                         return $this->mTitle;
820                 }
821                 // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
822                 if ( $this->mId !== null ) {
823                         $dbr = wfGetLB( $this->mWiki )->getConnectionRef( DB_REPLICA, [], $this->mWiki );
824                         $row = $dbr->selectRow(
825                                 [ 'page', 'revision' ],
826                                 self::selectPageFields(),
827                                 [ 'page_id=rev_page', 'rev_id' => $this->mId ],
828                                 __METHOD__
829                         );
830                         if ( $row ) {
831                                 // @TODO: better foreign title handling
832                                 $this->mTitle = Title::newFromRow( $row );
833                         }
834                 }
835
836                 if ( $this->mWiki === false || $this->mWiki === wfWikiID() ) {
837                         // Loading by ID is best, though not possible for foreign titles
838                         if ( !$this->mTitle && $this->mPage !== null && $this->mPage > 0 ) {
839                                 $this->mTitle = Title::newFromID( $this->mPage );
840                         }
841                 }
842
843                 return $this->mTitle;
844         }
845
846         /**
847          * Set the title of the revision
848          *
849          * @param Title $title
850          */
851         public function setTitle( $title ) {
852                 $this->mTitle = $title;
853         }
854
855         /**
856          * Get the page ID
857          *
858          * @return int|null
859          */
860         public function getPage() {
861                 return $this->mPage;
862         }
863
864         /**
865          * Fetch revision's user id if it's available to the specified audience.
866          * If the specified audience does not have access to it, zero will be
867          * returned.
868          *
869          * @param int $audience One of:
870          *   Revision::FOR_PUBLIC       to be displayed to all users
871          *   Revision::FOR_THIS_USER    to be displayed to the given user
872          *   Revision::RAW              get the ID regardless of permissions
873          * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
874          *   to the $audience parameter
875          * @return int
876          */
877         public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) {
878                 if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_USER ) ) {
879                         return 0;
880                 } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_USER, $user ) ) {
881                         return 0;
882                 } else {
883                         return $this->mUser;
884                 }
885         }
886
887         /**
888          * Fetch revision's user id without regard for the current user's permissions
889          *
890          * @return int
891          * @deprecated since 1.25, use getUser( Revision::RAW )
892          */
893         public function getRawUser() {
894                 wfDeprecated( __METHOD__, '1.25' );
895                 return $this->getUser( self::RAW );
896         }
897
898         /**
899          * Fetch revision's username if it's available to the specified audience.
900          * If the specified audience does not have access to the username, an
901          * empty string will be returned.
902          *
903          * @param int $audience One of:
904          *   Revision::FOR_PUBLIC       to be displayed to all users
905          *   Revision::FOR_THIS_USER    to be displayed to the given user
906          *   Revision::RAW              get the text regardless of permissions
907          * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
908          *   to the $audience parameter
909          * @return string
910          */
911         public function getUserText( $audience = self::FOR_PUBLIC, User $user = null ) {
912                 $this->loadMutableFields();
913
914                 if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_USER ) ) {
915                         return '';
916                 } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_USER, $user ) ) {
917                         return '';
918                 } else {
919                         if ( $this->mUserText === null ) {
920                                 $this->mUserText = User::whoIs( $this->mUser ); // load on demand
921                                 if ( $this->mUserText === false ) {
922                                         # This shouldn't happen, but it can if the wiki was recovered
923                                         # via importing revs and there is no user table entry yet.
924                                         $this->mUserText = $this->mOrigUserText;
925                                 }
926                         }
927                         return $this->mUserText;
928                 }
929         }
930
931         /**
932          * Fetch revision's username without regard for view restrictions
933          *
934          * @return string
935          * @deprecated since 1.25, use getUserText( Revision::RAW )
936          */
937         public function getRawUserText() {
938                 wfDeprecated( __METHOD__, '1.25' );
939                 return $this->getUserText( self::RAW );
940         }
941
942         /**
943          * Fetch revision comment if it's available to the specified audience.
944          * If the specified audience does not have access to the comment, an
945          * empty string will be returned.
946          *
947          * @param int $audience One of:
948          *   Revision::FOR_PUBLIC       to be displayed to all users
949          *   Revision::FOR_THIS_USER    to be displayed to the given user
950          *   Revision::RAW              get the text regardless of permissions
951          * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
952          *   to the $audience parameter
953          * @return string
954          */
955         function getComment( $audience = self::FOR_PUBLIC, User $user = null ) {
956                 if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_COMMENT ) ) {
957                         return '';
958                 } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_COMMENT, $user ) ) {
959                         return '';
960                 } else {
961                         return $this->mComment;
962                 }
963         }
964
965         /**
966          * Fetch revision comment without regard for the current user's permissions
967          *
968          * @return string
969          * @deprecated since 1.25, use getComment( Revision::RAW )
970          */
971         public function getRawComment() {
972                 wfDeprecated( __METHOD__, '1.25' );
973                 return $this->getComment( self::RAW );
974         }
975
976         /**
977          * @return bool
978          */
979         public function isMinor() {
980                 return (bool)$this->mMinorEdit;
981         }
982
983         /**
984          * @return int Rcid of the unpatrolled row, zero if there isn't one
985          */
986         public function isUnpatrolled() {
987                 if ( $this->mUnpatrolled !== null ) {
988                         return $this->mUnpatrolled;
989                 }
990                 $rc = $this->getRecentChange();
991                 if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == 0 ) {
992                         $this->mUnpatrolled = $rc->getAttribute( 'rc_id' );
993                 } else {
994                         $this->mUnpatrolled = 0;
995                 }
996                 return $this->mUnpatrolled;
997         }
998
999         /**
1000          * Get the RC object belonging to the current revision, if there's one
1001          *
1002          * @param int $flags (optional) $flags include:
1003          *      Revision::READ_LATEST  : Select the data from the master
1004          *
1005          * @since 1.22
1006          * @return RecentChange|null
1007          */
1008         public function getRecentChange( $flags = 0 ) {
1009                 $dbr = wfGetDB( DB_REPLICA );
1010
1011                 list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
1012
1013                 return RecentChange::newFromConds(
1014                         [
1015                                 'rc_user_text' => $this->getUserText( self::RAW ),
1016                                 'rc_timestamp' => $dbr->timestamp( $this->getTimestamp() ),
1017                                 'rc_this_oldid' => $this->getId()
1018                         ],
1019                         __METHOD__,
1020                         $dbType
1021                 );
1022         }
1023
1024         /**
1025          * @param int $field One of DELETED_* bitfield constants
1026          *
1027          * @return bool
1028          */
1029         public function isDeleted( $field ) {
1030                 if ( $this->isCurrent() && $field === self::DELETED_TEXT ) {
1031                         // Current revisions of pages cannot have the content hidden. Skipping this
1032                         // check is very useful for Parser as it fetches templates using newKnownCurrent().
1033                         // Calling getVisibility() in that case triggers a verification database query.
1034                         return false; // no need to check
1035                 }
1036
1037                 return ( $this->getVisibility() & $field ) == $field;
1038         }
1039
1040         /**
1041          * Get the deletion bitfield of the revision
1042          *
1043          * @return int
1044          */
1045         public function getVisibility() {
1046                 $this->loadMutableFields();
1047
1048                 return (int)$this->mDeleted;
1049         }
1050
1051         /**
1052          * Fetch revision content if it's available to the specified audience.
1053          * If the specified audience does not have the ability to view this
1054          * revision, null will be returned.
1055          *
1056          * @param int $audience One of:
1057          *   Revision::FOR_PUBLIC       to be displayed to all users
1058          *   Revision::FOR_THIS_USER    to be displayed to $wgUser
1059          *   Revision::RAW              get the text regardless of permissions
1060          * @param User $user User object to check for, only if FOR_THIS_USER is passed
1061          *   to the $audience parameter
1062          * @since 1.21
1063          * @return Content|null
1064          */
1065         public function getContent( $audience = self::FOR_PUBLIC, User $user = null ) {
1066                 if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_TEXT ) ) {
1067                         return null;
1068                 } elseif ( $audience == self::FOR_THIS_USER && !$this->userCan( self::DELETED_TEXT, $user ) ) {
1069                         return null;
1070                 } else {
1071                         return $this->getContentInternal();
1072                 }
1073         }
1074
1075         /**
1076          * Get original serialized data (without checking view restrictions)
1077          *
1078          * @since 1.21
1079          * @return string
1080          */
1081         public function getSerializedData() {
1082                 if ( $this->mText === null ) {
1083                         // Revision is immutable. Load on demand.
1084                         $this->mText = $this->loadText();
1085                 }
1086
1087                 return $this->mText;
1088         }
1089
1090         /**
1091          * Gets the content object for the revision (or null on failure).
1092          *
1093          * Note that for mutable Content objects, each call to this method will return a
1094          * fresh clone.
1095          *
1096          * @since 1.21
1097          * @return Content|null The Revision's content, or null on failure.
1098          */
1099         protected function getContentInternal() {
1100                 if ( $this->mContent === null ) {
1101                         $text = $this->getSerializedData();
1102
1103                         if ( $text !== null && $text !== false ) {
1104                                 // Unserialize content
1105                                 $handler = $this->getContentHandler();
1106                                 $format = $this->getContentFormat();
1107
1108                                 $this->mContent = $handler->unserializeContent( $text, $format );
1109                         }
1110                 }
1111
1112                 // NOTE: copy() will return $this for immutable content objects
1113                 return $this->mContent ? $this->mContent->copy() : null;
1114         }
1115
1116         /**
1117          * Returns the content model for this revision.
1118          *
1119          * If no content model was stored in the database, the default content model for the title is
1120          * used to determine the content model to use. If no title is know, CONTENT_MODEL_WIKITEXT
1121          * is used as a last resort.
1122          *
1123          * @return string The content model id associated with this revision,
1124          *     see the CONTENT_MODEL_XXX constants.
1125          */
1126         public function getContentModel() {
1127                 if ( !$this->mContentModel ) {
1128                         $title = $this->getTitle();
1129                         if ( $title ) {
1130                                 $this->mContentModel = ContentHandler::getDefaultModelFor( $title );
1131                         } else {
1132                                 $this->mContentModel = CONTENT_MODEL_WIKITEXT;
1133                         }
1134
1135                         assert( !empty( $this->mContentModel ) );
1136                 }
1137
1138                 return $this->mContentModel;
1139         }
1140
1141         /**
1142          * Returns the content format for this revision.
1143          *
1144          * If no content format was stored in the database, the default format for this
1145          * revision's content model is returned.
1146          *
1147          * @return string The content format id associated with this revision,
1148          *     see the CONTENT_FORMAT_XXX constants.
1149          */
1150         public function getContentFormat() {
1151                 if ( !$this->mContentFormat ) {
1152                         $handler = $this->getContentHandler();
1153                         $this->mContentFormat = $handler->getDefaultFormat();
1154
1155                         assert( !empty( $this->mContentFormat ) );
1156                 }
1157
1158                 return $this->mContentFormat;
1159         }
1160
1161         /**
1162          * Returns the content handler appropriate for this revision's content model.
1163          *
1164          * @throws MWException
1165          * @return ContentHandler
1166          */
1167         public function getContentHandler() {
1168                 if ( !$this->mContentHandler ) {
1169                         $model = $this->getContentModel();
1170                         $this->mContentHandler = ContentHandler::getForModelID( $model );
1171
1172                         $format = $this->getContentFormat();
1173
1174                         if ( !$this->mContentHandler->isSupportedFormat( $format ) ) {
1175                                 throw new MWException( "Oops, the content format $format is not supported for "
1176                                         . "this content model, $model" );
1177                         }
1178                 }
1179
1180                 return $this->mContentHandler;
1181         }
1182
1183         /**
1184          * @return string
1185          */
1186         public function getTimestamp() {
1187                 return wfTimestamp( TS_MW, $this->mTimestamp );
1188         }
1189
1190         /**
1191          * @return bool
1192          */
1193         public function isCurrent() {
1194                 return $this->mCurrent;
1195         }
1196
1197         /**
1198          * Get previous revision for this title
1199          *
1200          * @return Revision|null
1201          */
1202         public function getPrevious() {
1203                 if ( $this->getTitle() ) {
1204                         $prev = $this->getTitle()->getPreviousRevisionID( $this->getId() );
1205                         if ( $prev ) {
1206                                 return self::newFromTitle( $this->getTitle(), $prev );
1207                         }
1208                 }
1209                 return null;
1210         }
1211
1212         /**
1213          * Get next revision for this title
1214          *
1215          * @return Revision|null
1216          */
1217         public function getNext() {
1218                 if ( $this->getTitle() ) {
1219                         $next = $this->getTitle()->getNextRevisionID( $this->getId() );
1220                         if ( $next ) {
1221                                 return self::newFromTitle( $this->getTitle(), $next );
1222                         }
1223                 }
1224                 return null;
1225         }
1226
1227         /**
1228          * Get previous revision Id for this page_id
1229          * This is used to populate rev_parent_id on save
1230          *
1231          * @param IDatabase $db
1232          * @return int
1233          */
1234         private function getPreviousRevisionId( $db ) {
1235                 if ( $this->mPage === null ) {
1236                         return 0;
1237                 }
1238                 # Use page_latest if ID is not given
1239                 if ( !$this->mId ) {
1240                         $prevId = $db->selectField( 'page', 'page_latest',
1241                                 [ 'page_id' => $this->mPage ],
1242                                 __METHOD__ );
1243                 } else {
1244                         $prevId = $db->selectField( 'revision', 'rev_id',
1245                                 [ 'rev_page' => $this->mPage, 'rev_id < ' . $this->mId ],
1246                                 __METHOD__,
1247                                 [ 'ORDER BY' => 'rev_id DESC' ] );
1248                 }
1249                 return intval( $prevId );
1250         }
1251
1252         /**
1253          * Get revision text associated with an old or archive row
1254          *
1255          * Both the flags and the text field must be included. Including the old_id
1256          * field will activate cache usage as long as the $wiki parameter is not set.
1257          *
1258          * @param stdClass $row The text data
1259          * @param string $prefix Table prefix (default 'old_')
1260          * @param string|bool $wiki The name of the wiki to load the revision text from
1261          *   (same as the the wiki $row was loaded from) or false to indicate the local
1262          *   wiki (this is the default). Otherwise, it must be a symbolic wiki database
1263          *   identifier as understood by the LoadBalancer class.
1264          * @return string|false Text the text requested or false on failure
1265          */
1266         public static function getRevisionText( $row, $prefix = 'old_', $wiki = false ) {
1267                 $textField = $prefix . 'text';
1268                 $flagsField = $prefix . 'flags';
1269
1270                 if ( isset( $row->$flagsField ) ) {
1271                         $flags = explode( ',', $row->$flagsField );
1272                 } else {
1273                         $flags = [];
1274                 }
1275
1276                 if ( isset( $row->$textField ) ) {
1277                         $text = $row->$textField;
1278                 } else {
1279                         return false;
1280                 }
1281
1282                 // Use external methods for external objects, text in table is URL-only then
1283                 if ( in_array( 'external', $flags ) ) {
1284                         $url = $text;
1285                         $parts = explode( '://', $url, 2 );
1286                         if ( count( $parts ) == 1 || $parts[1] == '' ) {
1287                                 return false;
1288                         }
1289
1290                         if ( isset( $row->old_id ) && $wiki === false ) {
1291                                 // Make use of the wiki-local revision text cache
1292                                 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1293                                 // The cached value should be decompressed, so handle that and return here
1294                                 return $cache->getWithSetCallback(
1295                                         $cache->makeKey( 'revisiontext', 'textid', $row->old_id ),
1296                                         self::getCacheTTL( $cache ),
1297                                         function () use ( $url, $wiki, $flags ) {
1298                                                 // No negative caching per Revision::loadText()
1299                                                 $text = ExternalStore::fetchFromURL( $url, [ 'wiki' => $wiki ] );
1300
1301                                                 return self::decompressRevisionText( $text, $flags );
1302                                         },
1303                                         [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => $cache::TTL_PROC_LONG ]
1304                                 );
1305                         } else {
1306                                 $text = ExternalStore::fetchFromURL( $url, [ 'wiki' => $wiki ] );
1307                         }
1308                 }
1309
1310                 return self::decompressRevisionText( $text, $flags );
1311         }
1312
1313         /**
1314          * If $wgCompressRevisions is enabled, we will compress data.
1315          * The input string is modified in place.
1316          * Return value is the flags field: contains 'gzip' if the
1317          * data is compressed, and 'utf-8' if we're saving in UTF-8
1318          * mode.
1319          *
1320          * @param mixed &$text Reference to a text
1321          * @return string
1322          */
1323         public static function compressRevisionText( &$text ) {
1324                 global $wgCompressRevisions;
1325                 $flags = [];
1326
1327                 # Revisions not marked this way will be converted
1328                 # on load if $wgLegacyCharset is set in the future.
1329                 $flags[] = 'utf-8';
1330
1331                 if ( $wgCompressRevisions ) {
1332                         if ( function_exists( 'gzdeflate' ) ) {
1333                                 $deflated = gzdeflate( $text );
1334
1335                                 if ( $deflated === false ) {
1336                                         wfLogWarning( __METHOD__ . ': gzdeflate() failed' );
1337                                 } else {
1338                                         $text = $deflated;
1339                                         $flags[] = 'gzip';
1340                                 }
1341                         } else {
1342                                 wfDebug( __METHOD__ . " -- no zlib support, not compressing\n" );
1343                         }
1344                 }
1345                 return implode( ',', $flags );
1346         }
1347
1348         /**
1349          * Re-converts revision text according to it's flags.
1350          *
1351          * @param mixed $text Reference to a text
1352          * @param array $flags Compression flags
1353          * @return string|bool Decompressed text, or false on failure
1354          */
1355         public static function decompressRevisionText( $text, $flags ) {
1356                 global $wgLegacyEncoding, $wgContLang;
1357
1358                 if ( $text === false ) {
1359                         // Text failed to be fetched; nothing to do
1360                         return false;
1361                 }
1362
1363                 if ( in_array( 'gzip', $flags ) ) {
1364                         # Deal with optional compression of archived pages.
1365                         # This can be done periodically via maintenance/compressOld.php, and
1366                         # as pages are saved if $wgCompressRevisions is set.
1367                         $text = gzinflate( $text );
1368
1369                         if ( $text === false ) {
1370                                 wfLogWarning( __METHOD__ . ': gzinflate() failed' );
1371                                 return false;
1372                         }
1373                 }
1374
1375                 if ( in_array( 'object', $flags ) ) {
1376                         # Generic compressed storage
1377                         $obj = unserialize( $text );
1378                         if ( !is_object( $obj ) ) {
1379                                 // Invalid object
1380                                 return false;
1381                         }
1382                         $text = $obj->getText();
1383                 }
1384
1385                 if ( $text !== false && $wgLegacyEncoding
1386                         && !in_array( 'utf-8', $flags ) && !in_array( 'utf8', $flags )
1387                 ) {
1388                         # Old revisions kept around in a legacy encoding?
1389                         # Upconvert on demand.
1390                         # ("utf8" checked for compatibility with some broken
1391                         #  conversion scripts 2008-12-30)
1392                         $text = $wgContLang->iconv( $wgLegacyEncoding, 'UTF-8', $text );
1393                 }
1394
1395                 return $text;
1396         }
1397
1398         /**
1399          * Insert a new revision into the database, returning the new revision ID
1400          * number on success and dies horribly on failure.
1401          *
1402          * @param IDatabase $dbw (master connection)
1403          * @throws MWException
1404          * @return int The revision ID
1405          */
1406         public function insertOn( $dbw ) {
1407                 global $wgDefaultExternalStore, $wgContentHandlerUseDB;
1408
1409                 // We're inserting a new revision, so we have to use master anyway.
1410                 // If it's a null revision, it may have references to rows that
1411                 // are not in the replica yet (the text row).
1412                 $this->mQueryFlags |= self::READ_LATEST;
1413
1414                 // Not allowed to have rev_page equal to 0, false, etc.
1415                 if ( !$this->mPage ) {
1416                         $title = $this->getTitle();
1417                         if ( $title instanceof Title ) {
1418                                 $titleText = ' for page ' . $title->getPrefixedText();
1419                         } else {
1420                                 $titleText = '';
1421                         }
1422                         throw new MWException( "Cannot insert revision$titleText: page ID must be nonzero" );
1423                 }
1424
1425                 $this->checkContentModel();
1426
1427                 $data = $this->mText;
1428                 $flags = self::compressRevisionText( $data );
1429
1430                 # Write to external storage if required
1431                 if ( $wgDefaultExternalStore ) {
1432                         // Store and get the URL
1433                         $data = ExternalStore::insertToDefault( $data );
1434                         if ( !$data ) {
1435                                 throw new MWException( "Unable to store text to external storage" );
1436                         }
1437                         if ( $flags ) {
1438                                 $flags .= ',';
1439                         }
1440                         $flags .= 'external';
1441                 }
1442
1443                 # Record the text (or external storage URL) to the text table
1444                 if ( $this->mTextId === null ) {
1445                         $dbw->insert( 'text',
1446                                 [
1447                                         'old_text' => $data,
1448                                         'old_flags' => $flags,
1449                                 ], __METHOD__
1450                         );
1451                         $this->mTextId = $dbw->insertId();
1452                 }
1453
1454                 if ( $this->mComment === null ) {
1455                         $this->mComment = "";
1456                 }
1457
1458                 # Record the edit in revisions
1459                 $row = [
1460                         'rev_page'       => $this->mPage,
1461                         'rev_text_id'    => $this->mTextId,
1462                         'rev_minor_edit' => $this->mMinorEdit ? 1 : 0,
1463                         'rev_user'       => $this->mUser,
1464                         'rev_user_text'  => $this->mUserText,
1465                         'rev_timestamp'  => $dbw->timestamp( $this->mTimestamp ),
1466                         'rev_deleted'    => $this->mDeleted,
1467                         'rev_len'        => $this->mSize,
1468                         'rev_parent_id'  => $this->mParentId === null
1469                                 ? $this->getPreviousRevisionId( $dbw )
1470                                 : $this->mParentId,
1471                         'rev_sha1'       => $this->mSha1 === null
1472                                 ? self::base36Sha1( $this->mText )
1473                                 : $this->mSha1,
1474                 ];
1475                 if ( $this->mId !== null ) {
1476                         $row['rev_id'] = $this->mId;
1477                 }
1478
1479                 list( $commentFields, $commentCallback ) =
1480                         CommentStore::newKey( 'rev_comment' )->insertWithTempTable( $dbw, $this->mComment );
1481                 $row += $commentFields;
1482
1483                 if ( $wgContentHandlerUseDB ) {
1484                         // NOTE: Store null for the default model and format, to save space.
1485                         // XXX: Makes the DB sensitive to changed defaults.
1486                         // Make this behavior optional? Only in miser mode?
1487
1488                         $model = $this->getContentModel();
1489                         $format = $this->getContentFormat();
1490
1491                         $title = $this->getTitle();
1492
1493                         if ( $title === null ) {
1494                                 throw new MWException( "Insufficient information to determine the title of the "
1495                                         . "revision's page!" );
1496                         }
1497
1498                         $defaultModel = ContentHandler::getDefaultModelFor( $title );
1499                         $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat();
1500
1501                         $row['rev_content_model'] = ( $model === $defaultModel ) ? null : $model;
1502                         $row['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format;
1503                 }
1504
1505                 $dbw->insert( 'revision', $row, __METHOD__ );
1506
1507                 if ( $this->mId === null ) {
1508                         // Only if auto-increment was used
1509                         $this->mId = $dbw->insertId();
1510                 }
1511                 $commentCallback( $this->mId );
1512
1513                 // Assertion to try to catch T92046
1514                 if ( (int)$this->mId === 0 ) {
1515                         throw new UnexpectedValueException(
1516                                 'After insert, Revision mId is ' . var_export( $this->mId, 1 ) . ': ' .
1517                                         var_export( $row, 1 )
1518                         );
1519                 }
1520
1521                 // Insert IP revision into ip_changes for use when querying for a range.
1522                 if ( $this->mUser === 0 && IP::isValid( $this->mUserText ) ) {
1523                         $ipcRow = [
1524                                 'ipc_rev_id'        => $this->mId,
1525                                 'ipc_rev_timestamp' => $row['rev_timestamp'],
1526                                 'ipc_hex'           => IP::toHex( $row['rev_user_text'] ),
1527                         ];
1528                         $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ );
1529                 }
1530
1531                 // Avoid PHP 7.1 warning of passing $this by reference
1532                 $revision = $this;
1533                 Hooks::run( 'RevisionInsertComplete', [ &$revision, $data, $flags ] );
1534
1535                 return $this->mId;
1536         }
1537
1538         protected function checkContentModel() {
1539                 global $wgContentHandlerUseDB;
1540
1541                 // Note: may return null for revisions that have not yet been inserted
1542                 $title = $this->getTitle();
1543
1544                 $model = $this->getContentModel();
1545                 $format = $this->getContentFormat();
1546                 $handler = $this->getContentHandler();
1547
1548                 if ( !$handler->isSupportedFormat( $format ) ) {
1549                         $t = $title->getPrefixedDBkey();
1550
1551                         throw new MWException( "Can't use format $format with content model $model on $t" );
1552                 }
1553
1554                 if ( !$wgContentHandlerUseDB && $title ) {
1555                         // if $wgContentHandlerUseDB is not set,
1556                         // all revisions must use the default content model and format.
1557
1558                         $defaultModel = ContentHandler::getDefaultModelFor( $title );
1559                         $defaultHandler = ContentHandler::getForModelID( $defaultModel );
1560                         $defaultFormat = $defaultHandler->getDefaultFormat();
1561
1562                         if ( $this->getContentModel() != $defaultModel ) {
1563                                 $t = $title->getPrefixedDBkey();
1564
1565                                 throw new MWException( "Can't save non-default content model with "
1566                                         . "\$wgContentHandlerUseDB disabled: model is $model, "
1567                                         . "default for $t is $defaultModel" );
1568                         }
1569
1570                         if ( $this->getContentFormat() != $defaultFormat ) {
1571                                 $t = $title->getPrefixedDBkey();
1572
1573                                 throw new MWException( "Can't use non-default content format with "
1574                                         . "\$wgContentHandlerUseDB disabled: format is $format, "
1575                                         . "default for $t is $defaultFormat" );
1576                         }
1577                 }
1578
1579                 $content = $this->getContent( self::RAW );
1580                 $prefixedDBkey = $title->getPrefixedDBkey();
1581                 $revId = $this->mId;
1582
1583                 if ( !$content ) {
1584                         throw new MWException(
1585                                 "Content of revision $revId ($prefixedDBkey) could not be loaded for validation!"
1586                         );
1587                 }
1588                 if ( !$content->isValid() ) {
1589                         throw new MWException(
1590                                 "Content of revision $revId ($prefixedDBkey) is not valid! Content model is $model"
1591                         );
1592                 }
1593         }
1594
1595         /**
1596          * Get the base 36 SHA-1 value for a string of text
1597          * @param string $text
1598          * @return string
1599          */
1600         public static function base36Sha1( $text ) {
1601                 return Wikimedia\base_convert( sha1( $text ), 16, 36, 31 );
1602         }
1603
1604         /**
1605          * Get the text cache TTL
1606          *
1607          * @param WANObjectCache $cache
1608          * @return int
1609          */
1610         private static function getCacheTTL( WANObjectCache $cache ) {
1611                 global $wgRevisionCacheExpiry;
1612
1613                 if ( $cache->getQoS( $cache::ATTR_EMULATION ) <= $cache::QOS_EMULATION_SQL ) {
1614                         // Do not cache RDBMs blobs in...the RDBMs store
1615                         $ttl = $cache::TTL_UNCACHEABLE;
1616                 } else {
1617                         $ttl = $wgRevisionCacheExpiry ?: $cache::TTL_UNCACHEABLE;
1618                 }
1619
1620                 return $ttl;
1621         }
1622
1623         /**
1624          * Lazy-load the revision's text.
1625          * Currently hardcoded to the 'text' table storage engine.
1626          *
1627          * @return string|bool The revision's text, or false on failure
1628          */
1629         private function loadText() {
1630                 $cache = ObjectCache::getMainWANInstance();
1631
1632                 // No negative caching; negative hits on text rows may be due to corrupted replica DBs
1633                 return $cache->getWithSetCallback(
1634                         $cache->makeKey( 'revisiontext', 'textid', $this->getTextId() ),
1635                         self::getCacheTTL( $cache ),
1636                         function () {
1637                                 return $this->fetchText();
1638                         },
1639                         [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => $cache::TTL_PROC_LONG ]
1640                 );
1641         }
1642
1643         private function fetchText() {
1644                 $textId = $this->getTextId();
1645
1646                 // If we kept data for lazy extraction, use it now...
1647                 if ( $this->mTextRow !== null ) {
1648                         $row = $this->mTextRow;
1649                         $this->mTextRow = null;
1650                 } else {
1651                         $row = null;
1652                 }
1653
1654                 // Callers doing updates will pass in READ_LATEST as usual. Since the text/blob tables
1655                 // do not normally get rows changed around, set READ_LATEST_IMMUTABLE in those cases.
1656                 $flags = $this->mQueryFlags;
1657                 $flags |= DBAccessObjectUtils::hasFlags( $flags, self::READ_LATEST )
1658                         ? self::READ_LATEST_IMMUTABLE
1659                         : 0;
1660
1661                 list( $index, $options, $fallbackIndex, $fallbackOptions ) =
1662                         DBAccessObjectUtils::getDBOptions( $flags );
1663
1664                 if ( !$row ) {
1665                         // Text data is immutable; check replica DBs first.
1666                         $row = wfGetDB( $index )->selectRow(
1667                                 'text',
1668                                 [ 'old_text', 'old_flags' ],
1669                                 [ 'old_id' => $textId ],
1670                                 __METHOD__,
1671                                 $options
1672                         );
1673                 }
1674
1675                 // Fallback to DB_MASTER in some cases if the row was not found
1676                 if ( !$row && $fallbackIndex !== null ) {
1677                         // Use FOR UPDATE if it was used to fetch this revision. This avoids missing the row
1678                         // due to REPEATABLE-READ. Also fallback to the master if READ_LATEST is provided.
1679                         $row = wfGetDB( $fallbackIndex )->selectRow(
1680                                 'text',
1681                                 [ 'old_text', 'old_flags' ],
1682                                 [ 'old_id' => $textId ],
1683                                 __METHOD__,
1684                                 $fallbackOptions
1685                         );
1686                 }
1687
1688                 if ( !$row ) {
1689                         wfDebugLog( 'Revision', "No text row with ID '$textId' (revision {$this->getId()})." );
1690                 }
1691
1692                 $text = self::getRevisionText( $row );
1693                 if ( $row && $text === false ) {
1694                         wfDebugLog( 'Revision', "No blob for text row '$textId' (revision {$this->getId()})." );
1695                 }
1696
1697                 return is_string( $text ) ? $text : false;
1698         }
1699
1700         /**
1701          * Create a new null-revision for insertion into a page's
1702          * history. This will not re-save the text, but simply refer
1703          * to the text from the previous version.
1704          *
1705          * Such revisions can for instance identify page rename
1706          * operations and other such meta-modifications.
1707          *
1708          * @param IDatabase $dbw
1709          * @param int $pageId ID number of the page to read from
1710          * @param string $summary Revision's summary
1711          * @param bool $minor Whether the revision should be considered as minor
1712          * @param User|null $user User object to use or null for $wgUser
1713          * @return Revision|null Revision or null on error
1714          */
1715         public static function newNullRevision( $dbw, $pageId, $summary, $minor, $user = null ) {
1716                 global $wgContentHandlerUseDB;
1717
1718                 $fields = [ 'page_latest', 'page_namespace', 'page_title',
1719                                                 'rev_text_id', 'rev_len', 'rev_sha1' ];
1720
1721                 if ( $wgContentHandlerUseDB ) {
1722                         $fields[] = 'rev_content_model';
1723                         $fields[] = 'rev_content_format';
1724                 }
1725
1726                 $current = $dbw->selectRow(
1727                         [ 'page', 'revision' ],
1728                         $fields,
1729                         [
1730                                 'page_id' => $pageId,
1731                                 'page_latest=rev_id',
1732                         ],
1733                         __METHOD__,
1734                         [ 'FOR UPDATE' ] // T51581
1735                 );
1736
1737                 if ( $current ) {
1738                         if ( !$user ) {
1739                                 global $wgUser;
1740                                 $user = $wgUser;
1741                         }
1742
1743                         $row = [
1744                                 'page'       => $pageId,
1745                                 'user_text'  => $user->getName(),
1746                                 'user'       => $user->getId(),
1747                                 'comment'    => $summary,
1748                                 'minor_edit' => $minor,
1749                                 'text_id'    => $current->rev_text_id,
1750                                 'parent_id'  => $current->page_latest,
1751                                 'len'        => $current->rev_len,
1752                                 'sha1'       => $current->rev_sha1
1753                         ];
1754
1755                         if ( $wgContentHandlerUseDB ) {
1756                                 $row['content_model'] = $current->rev_content_model;
1757                                 $row['content_format'] = $current->rev_content_format;
1758                         }
1759
1760                         $row['title'] = Title::makeTitle( $current->page_namespace, $current->page_title );
1761
1762                         $revision = new Revision( $row );
1763                 } else {
1764                         $revision = null;
1765                 }
1766
1767                 return $revision;
1768         }
1769
1770         /**
1771          * Determine if the current user is allowed to view a particular
1772          * field of this revision, if it's marked as deleted.
1773          *
1774          * @param int $field One of self::DELETED_TEXT,
1775          *                              self::DELETED_COMMENT,
1776          *                              self::DELETED_USER
1777          * @param User|null $user User object to check, or null to use $wgUser
1778          * @return bool
1779          */
1780         public function userCan( $field, User $user = null ) {
1781                 return self::userCanBitfield( $this->getVisibility(), $field, $user );
1782         }
1783
1784         /**
1785          * Determine if the current user is allowed to view a particular
1786          * field of this revision, if it's marked as deleted. This is used
1787          * by various classes to avoid duplication.
1788          *
1789          * @param int $bitfield Current field
1790          * @param int $field One of self::DELETED_TEXT = File::DELETED_FILE,
1791          *                               self::DELETED_COMMENT = File::DELETED_COMMENT,
1792          *                               self::DELETED_USER = File::DELETED_USER
1793          * @param User|null $user User object to check, or null to use $wgUser
1794          * @param Title|null $title A Title object to check for per-page restrictions on,
1795          *                          instead of just plain userrights
1796          * @return bool
1797          */
1798         public static function userCanBitfield( $bitfield, $field, User $user = null,
1799                 Title $title = null
1800         ) {
1801                 if ( $bitfield & $field ) { // aspect is deleted
1802                         if ( $user === null ) {
1803                                 global $wgUser;
1804                                 $user = $wgUser;
1805                         }
1806                         if ( $bitfield & self::DELETED_RESTRICTED ) {
1807                                 $permissions = [ 'suppressrevision', 'viewsuppressed' ];
1808                         } elseif ( $field & self::DELETED_TEXT ) {
1809                                 $permissions = [ 'deletedtext' ];
1810                         } else {
1811                                 $permissions = [ 'deletedhistory' ];
1812                         }
1813                         $permissionlist = implode( ', ', $permissions );
1814                         if ( $title === null ) {
1815                                 wfDebug( "Checking for $permissionlist due to $field match on $bitfield\n" );
1816                                 return call_user_func_array( [ $user, 'isAllowedAny' ], $permissions );
1817                         } else {
1818                                 $text = $title->getPrefixedText();
1819                                 wfDebug( "Checking for $permissionlist on $text due to $field match on $bitfield\n" );
1820                                 foreach ( $permissions as $perm ) {
1821                                         if ( $title->userCan( $perm, $user ) ) {
1822                                                 return true;
1823                                         }
1824                                 }
1825                                 return false;
1826                         }
1827                 } else {
1828                         return true;
1829                 }
1830         }
1831
1832         /**
1833          * Get rev_timestamp from rev_id, without loading the rest of the row
1834          *
1835          * @param Title $title
1836          * @param int $id
1837          * @param int $flags
1838          * @return string|bool False if not found
1839          */
1840         static function getTimestampFromId( $title, $id, $flags = 0 ) {
1841                 $db = ( $flags & self::READ_LATEST )
1842                         ? wfGetDB( DB_MASTER )
1843                         : wfGetDB( DB_REPLICA );
1844                 // Casting fix for databases that can't take '' for rev_id
1845                 if ( $id == '' ) {
1846                         $id = 0;
1847                 }
1848                 $conds = [ 'rev_id' => $id ];
1849                 $conds['rev_page'] = $title->getArticleID();
1850                 $timestamp = $db->selectField( 'revision', 'rev_timestamp', $conds, __METHOD__ );
1851
1852                 return ( $timestamp !== false ) ? wfTimestamp( TS_MW, $timestamp ) : false;
1853         }
1854
1855         /**
1856          * Get count of revisions per page...not very efficient
1857          *
1858          * @param IDatabase $db
1859          * @param int $id Page id
1860          * @return int
1861          */
1862         static function countByPageId( $db, $id ) {
1863                 $row = $db->selectRow( 'revision', [ 'revCount' => 'COUNT(*)' ],
1864                         [ 'rev_page' => $id ], __METHOD__ );
1865                 if ( $row ) {
1866                         return $row->revCount;
1867                 }
1868                 return 0;
1869         }
1870
1871         /**
1872          * Get count of revisions per page...not very efficient
1873          *
1874          * @param IDatabase $db
1875          * @param Title $title
1876          * @return int
1877          */
1878         static function countByTitle( $db, $title ) {
1879                 $id = $title->getArticleID();
1880                 if ( $id ) {
1881                         return self::countByPageId( $db, $id );
1882                 }
1883                 return 0;
1884         }
1885
1886         /**
1887          * Check if no edits were made by other users since
1888          * the time a user started editing the page. Limit to
1889          * 50 revisions for the sake of performance.
1890          *
1891          * @since 1.20
1892          * @deprecated since 1.24
1893          *
1894          * @param IDatabase|int $db The Database to perform the check on. May be given as a
1895          *        Database object or a database identifier usable with wfGetDB.
1896          * @param int $pageId The ID of the page in question
1897          * @param int $userId The ID of the user in question
1898          * @param string $since Look at edits since this time
1899          *
1900          * @return bool True if the given user was the only one to edit since the given timestamp
1901          */
1902         public static function userWasLastToEdit( $db, $pageId, $userId, $since ) {
1903                 if ( !$userId ) {
1904                         return false;
1905                 }
1906
1907                 if ( is_int( $db ) ) {
1908                         $db = wfGetDB( $db );
1909                 }
1910
1911                 $res = $db->select( 'revision',
1912                         'rev_user',
1913                         [
1914                                 'rev_page' => $pageId,
1915                                 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) )
1916                         ],
1917                         __METHOD__,
1918                         [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ] );
1919                 foreach ( $res as $row ) {
1920                         if ( $row->rev_user != $userId ) {
1921                                 return false;
1922                         }
1923                 }
1924                 return true;
1925         }
1926
1927         /**
1928          * Load a revision based on a known page ID and current revision ID from the DB
1929          *
1930          * This method allows for the use of caching, though accessing anything that normally
1931          * requires permission checks (aside from the text) will trigger a small DB lookup.
1932          * The title will also be lazy loaded, though setTitle() can be used to preload it.
1933          *
1934          * @param IDatabase $db
1935          * @param int $pageId Page ID
1936          * @param int $revId Known current revision of this page
1937          * @return Revision|bool Returns false if missing
1938          * @since 1.28
1939          */
1940         public static function newKnownCurrent( IDatabase $db, $pageId, $revId ) {
1941                 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
1942                 return $cache->getWithSetCallback(
1943                         // Page/rev IDs passed in from DB to reflect history merges
1944                         $cache->makeGlobalKey( 'revision', $db->getDomainID(), $pageId, $revId ),
1945                         $cache::TTL_WEEK,
1946                         function ( $curValue, &$ttl, array &$setOpts ) use ( $db, $pageId, $revId ) {
1947                                 $setOpts += Database::getCacheSetOptions( $db );
1948
1949                                 $rev = Revision::loadFromPageId( $db, $pageId, $revId );
1950                                 // Reflect revision deletion and user renames
1951                                 if ( $rev ) {
1952                                         $rev->mTitle = null; // mutable; lazy-load
1953                                         $rev->mRefreshMutableFields = true;
1954                                 }
1955
1956                                 return $rev ?: false; // don't cache negatives
1957                         }
1958                 );
1959         }
1960
1961         /**
1962          * For cached revisions, make sure the user name and rev_deleted is up-to-date
1963          */
1964         private function loadMutableFields() {
1965                 if ( !$this->mRefreshMutableFields ) {
1966                         return; // not needed
1967                 }
1968
1969                 $this->mRefreshMutableFields = false;
1970                 $dbr = wfGetLB( $this->mWiki )->getConnectionRef( DB_REPLICA, [], $this->mWiki );
1971                 $row = $dbr->selectRow(
1972                         [ 'revision', 'user' ],
1973                         [ 'rev_deleted', 'user_name' ],
1974                         [ 'rev_id' => $this->mId, 'user_id = rev_user' ],
1975                         __METHOD__
1976                 );
1977                 if ( $row ) { // update values
1978                         $this->mDeleted = (int)$row->rev_deleted;
1979                         $this->mUserText = $row->user_name;
1980                 }
1981         }
1982 }