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