Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.81% covered (success)
98.81%
83 / 84
96.43% covered (success)
96.43%
27 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionRecord
98.81% covered (success)
98.81%
83 / 84
96.43% covered (success)
96.43%
27 / 28
52
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 hasSameContent
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 getContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getContentOrThrow
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getSlot
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 hasSlot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSlotRoles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSlots
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOriginalSlots
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInheritedSlots
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrimarySlots
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getParentId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSize
n/a
0 / 0
n/a
0 / 0
0
 getSha1
n/a
0 / 0
n/a
0 / 0
0
 getPageId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getWikiId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageAsLinkTarget
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUser
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getComment
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isMinor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDeleted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVisibility
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTimestamp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 audienceCan
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 userCan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 userCanBitfield
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 isReadyForInsertion
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 isCurrent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Page revision base class.
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
23namespace MediaWiki\Revision;
24
25use Content;
26use InvalidArgumentException;
27use MediaWiki\CommentStore\CommentStoreComment;
28use MediaWiki\DAO\WikiAwareEntity;
29use MediaWiki\DAO\WikiAwareEntityTrait;
30use MediaWiki\Linker\LinkTarget;
31use MediaWiki\Page\LegacyArticleIdAccess;
32use MediaWiki\Page\PageIdentity;
33use MediaWiki\Permissions\Authority;
34use MediaWiki\Title\Title;
35use MediaWiki\User\UserIdentity;
36use Wikimedia\NonSerializable\NonSerializableTrait;
37
38/**
39 * Page revision base class.
40 *
41 * RevisionRecords are considered value objects, but they may use callbacks for lazy loading.
42 * Note that while the base class has no setters, subclasses may offer a mutable interface.
43 *
44 * @since 1.31
45 * @since 1.32 Renamed from MediaWiki\Storage\RevisionRecord
46 */
47abstract class RevisionRecord implements WikiAwareEntity {
48    use LegacyArticleIdAccess;
49    use NonSerializableTrait;
50    use WikiAwareEntityTrait;
51
52    // RevisionRecord deletion constants
53    public const DELETED_TEXT = 1;
54    public const DELETED_COMMENT = 2;
55    public const DELETED_USER = 4;
56    public const DELETED_RESTRICTED = 8;
57    public const SUPPRESSED_USER = self::DELETED_USER | self::DELETED_RESTRICTED; // convenience
58    public const SUPPRESSED_ALL = self::DELETED_TEXT | self::DELETED_COMMENT | self::DELETED_USER |
59        self::DELETED_RESTRICTED; // convenience
60
61    // Audience options for accessors
62    public const FOR_PUBLIC = 1;
63    public const FOR_THIS_USER = 2;
64    public const RAW = 3;
65
66    /** @var string|false Wiki ID; false means the current wiki */
67    protected $wikiId = false;
68    /** @var int|null */
69    protected $mId;
70    /** @var int */
71    protected $mPageId;
72    /** @var UserIdentity|null */
73    protected $mUser;
74    /** @var bool */
75    protected $mMinorEdit = false;
76    /** @var string|null */
77    protected $mTimestamp;
78    /** @var int using the DELETED_XXX and SUPPRESSED_XXX flags */
79    protected $mDeleted = 0;
80    /** @var int|null */
81    protected $mSize;
82    /** @var string|null */
83    protected $mSha1;
84    /** @var int|null */
85    protected $mParentId;
86    /** @var CommentStoreComment|null */
87    protected $mComment;
88
89    /** @var PageIdentity */
90    protected $mPage;
91
92    /** @var RevisionSlots */
93    protected $mSlots;
94
95    /**
96     * @note Avoid calling this constructor directly. Use the appropriate methods
97     * in RevisionStore instead.
98     *
99     * @param PageIdentity $page The page this RevisionRecord is associated with.
100     * @param RevisionSlots $slots The slots of this revision.
101     * @param false|string $wikiId Relevant wiki id or self::LOCAL for the current one.
102     */
103    public function __construct( PageIdentity $page, RevisionSlots $slots, $wikiId = self::LOCAL ) {
104        $this->assertWikiIdParam( $wikiId );
105
106        $this->mPage = $page;
107        $this->mSlots = $slots;
108        $this->wikiId = $wikiId;
109        $this->mPageId = $this->getArticleId( $page );
110    }
111
112    /**
113     * @param RevisionRecord $rec
114     *
115     * @return bool True if this RevisionRecord is known to have same content as $rec.
116     *         False if the content is different (or not known to be the same).
117     */
118    public function hasSameContent( RevisionRecord $rec ): bool {
119        if ( $rec === $this ) {
120            return true;
121        }
122
123        if ( $this->getId() !== null && $this->getId() === $rec->getId() ) {
124            return true;
125        }
126
127        // check size before hash, since size is quicker to compute
128        if ( $this->getSize() !== $rec->getSize() ) {
129            return false;
130        }
131
132        // instead of checking the hash, we could also check the content addresses of all slots.
133
134        if ( $this->getSha1() === $rec->getSha1() ) {
135            return true;
136        }
137
138        return false;
139    }
140
141    /**
142     * Returns the Content of the given slot of this revision.
143     * Call getSlotNames() to get a list of available slots.
144     *
145     * Note that for mutable Content objects, each call to this method will return a
146     * fresh clone.
147     *
148     * Use getContentOrThrow() for more specific error information.
149     *
150     * @param string $role The role name of the desired slot
151     * @param int $audience
152     * @param Authority|null $performer user on whose behalf to check
153     *
154     * @return Content|null The content of the given slot, or null on error
155     */
156    public function getContent( $role, $audience = self::FOR_PUBLIC, Authority $performer = null ): ?Content {
157        try {
158            $content = $this->getSlot( $role, $audience, $performer )->getContent();
159        } catch ( BadRevisionException | SuppressedDataException $e ) {
160            return null;
161        }
162        return $content->copy();
163    }
164
165    /**
166     * Get the Content of the given slot of this revision.
167     *
168     * @param string $role The role name of the desired slot
169     * @param int $audience
170     * @param Authority|null $performer user on whose behalf to check
171     *
172     * @return Content
173     * @throws SuppressedDataException if the content is not viewable by the given audience
174     * @throws BadRevisionException if the content is missing or corrupted
175     * @throws RevisionAccessException
176     */
177    public function getContentOrThrow( $role, $audience = self::FOR_PUBLIC, Authority $performer = null ): Content {
178        if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $performer ) ) {
179            throw new SuppressedDataException(
180                'Access to the content has been suppressed for this audience' );
181        }
182
183        $content = $this->getSlot( $role, $audience, $performer )->getContent();
184        return $content->copy();
185    }
186
187    /**
188     * Returns meta-data for the given slot.
189     *
190     * @param string $role The role name of the desired slot
191     * @param int $audience
192     * @param Authority|null $performer user on whose behalf to check
193     *
194     * @throws RevisionAccessException if the slot does not exist or slot data
195     *        could not be lazy-loaded.
196     * @return SlotRecord The slot meta-data. If access to the slot's content is forbidden,
197     *         calling getContent() on the SlotRecord will throw an exception.
198     */
199    public function getSlot( $role, $audience = self::FOR_PUBLIC, Authority $performer = null ): SlotRecord {
200        $slot = $this->mSlots->getSlot( $role );
201
202        if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $performer ) ) {
203            return SlotRecord::newWithSuppressedContent( $slot );
204        }
205
206        return $slot;
207    }
208
209    /**
210     * Returns whether the given slot is defined in this revision.
211     *
212     * @param string $role The role name of the desired slot
213     *
214     * @return bool
215     */
216    public function hasSlot( $role ): bool {
217        return $this->mSlots->hasSlot( $role );
218    }
219
220    /**
221     * Returns the slot names (roles) of all slots present in this revision.
222     * getContent() will succeed only for the names returned by this method.
223     *
224     * @return string[]
225     */
226    public function getSlotRoles(): array {
227        return $this->mSlots->getSlotRoles();
228    }
229
230    /**
231     * Returns the slots defined for this revision.
232     *
233     * @note This provides access to slot content with no audience checks applied.
234     * Calling getContent() on the RevisionSlots object returned here, or on any
235     * SlotRecord it returns from getSlot(), will not fail due to access restrictions.
236     * If audience checks are desired, use getSlot( $role, $audience, $performer )
237     * or getContent( $role, $audience, $performer ) instead.
238     *
239     * @return RevisionSlots
240     */
241    public function getSlots(): RevisionSlots {
242        return $this->mSlots;
243    }
244
245    /**
246     * Returns the slots that originate in this revision.
247     *
248     * Note that this does not include any slots inherited from some earlier revision,
249     * even if they are different from the slots in the immediate parent revision.
250     * This is the case for rollbacks: slots of a rollback revision are inherited from
251     * the rollback target, and are different from the slots in the parent revision,
252     * which was rolled back.
253     *
254     * To find all slots modified by this revision against its immediate parent
255     * revision, use RevisionSlotsUpdate::newFromRevisionSlots().
256     *
257     * @return RevisionSlots
258     */
259    public function getOriginalSlots(): RevisionSlots {
260        return new RevisionSlots( $this->mSlots->getOriginalSlots() );
261    }
262
263    /**
264     * Returns slots inherited from some previous revision.
265     *
266     * "Inherited" slots are all slots that do not originate in this revision.
267     * Note that these slots may still differ from the one in the parent revision.
268     * This is the case for rollbacks: slots of a rollback revision are inherited from
269     * the rollback target, and are different from the slots in the parent revision,
270     * which was rolled back.
271     *
272     * @return RevisionSlots
273     */
274    public function getInheritedSlots(): RevisionSlots {
275        return new RevisionSlots( $this->mSlots->getInheritedSlots() );
276    }
277
278    /**
279     * Returns primary slots (those that are not derived).
280     *
281     * @return RevisionSlots
282     * @since 1.36
283     */
284    public function getPrimarySlots(): RevisionSlots {
285        return new RevisionSlots( $this->mSlots->getPrimarySlots() );
286    }
287
288    /**
289     * Get revision ID. Depending on the concrete subclass, this may return null if
290     * the revision ID is not known (e.g. because the revision does not yet exist
291     * in the database).
292     *
293     * MCR migration note: this replaced Revision::getId
294     *
295     * @param string|false $wikiId The wiki ID expected by the caller.
296     * @return int|null
297     */
298    public function getId( $wikiId = self::LOCAL ) {
299        $this->deprecateInvalidCrossWiki( $wikiId, '1.36' );
300        return $this->mId;
301    }
302
303    /**
304     * Get parent revision ID (the original previous page revision).
305     * If there is no parent revision, this returns 0.
306     * If the parent revision is undefined or unknown, this returns null.
307     *
308     * @note As of MW 1.31, the database schema allows the parent ID to be
309     * NULL to indicate that it is unknown.
310     *
311     * MCR migration note: this replaced Revision::getParentId
312     *
313     * @param string|false $wikiId The wiki ID expected by the caller.
314     * @return int|null
315     */
316    public function getParentId( $wikiId = self::LOCAL ) {
317        $this->deprecateInvalidCrossWiki( $wikiId, '1.36' );
318        return $this->mParentId;
319    }
320
321    /**
322     * Returns the nominal size of this revision, in bogo-bytes.
323     * May be calculated on the fly if not known, which may in the worst
324     * case may involve loading all content.
325     *
326     * MCR migration note: this replaced Revision::getSize
327     *
328     * @throws RevisionAccessException if the size was unknown and could not be calculated.
329     * @return int
330     */
331    abstract public function getSize();
332
333    /**
334     * Returns the base36 sha1 of this revision. This hash is derived from the
335     * hashes of all slots associated with the revision.
336     * May be calculated on the fly if not known, which may in the worst
337     * case may involve loading all content.
338     *
339     * MCR migration note: this replaced Revision::getSha1
340     *
341     * @throws RevisionAccessException if the hash was unknown and could not be calculated.
342     * @return string
343     */
344    abstract public function getSha1();
345
346    /**
347     * Get the page ID. If the page does not yet exist, the page ID is 0.
348     *
349     * MCR migration note: this replaced Revision::getPage
350     *
351     * @param string|false $wikiId The wiki ID expected by the caller.
352     * @return int
353     */
354    public function getPageId( $wikiId = self::LOCAL ) {
355        $this->deprecateInvalidCrossWiki( $wikiId, '1.36' );
356        return $this->mPageId;
357    }
358
359    /**
360     * Get the ID of the wiki this revision belongs to.
361     *
362     * @return string|false The wiki's logical name, of false to indicate the local wiki.
363     */
364    public function getWikiId() {
365        return $this->wikiId;
366    }
367
368    /**
369     * Returns the title of the page this revision is associated with as a LinkTarget object.
370     *
371     * @throws InvalidArgumentException if this revision does not belong to a local wiki
372     * @return LinkTarget
373     */
374    public function getPageAsLinkTarget() {
375        // TODO: Should be TitleValue::newFromPage( $this->mPage ),
376        // but Title is used too much still, so let's keep propagating it
377        return Title::newFromPageIdentity( $this->mPage );
378    }
379
380    /**
381     * Returns the page this revision belongs to.
382     *
383     * MCR migration note: this replaced Revision::getTitle
384     *
385     * @since 1.36
386     *
387     * @return PageIdentity
388     */
389    public function getPage(): PageIdentity {
390        return $this->mPage;
391    }
392
393    /**
394     * Fetch revision's author's user identity, if it's available to the specified audience.
395     * If the specified audience does not have access to it, null will be
396     * returned. Depending on the concrete subclass, null may also be returned if the user is
397     * not yet specified.
398     *
399     * MCR migration note: this replaced Revision::getUser
400     *
401     * @param int $audience One of:
402     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
403     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
404     *   RevisionRecord::RAW              get the ID regardless of permissions
405     * @param Authority|null $performer user on whose behalf to check
406     * @return UserIdentity|null
407     */
408    public function getUser( $audience = self::FOR_PUBLIC, Authority $performer = null ) {
409        if ( !$this->audienceCan( self::DELETED_USER, $audience, $performer ) ) {
410            return null;
411        } else {
412            return $this->mUser;
413        }
414    }
415
416    /**
417     * Fetch revision comment, if it's available to the specified audience.
418     * If the specified audience does not have access to the comment,
419     * this will return null. Depending on the concrete subclass, null may also be returned
420     * if the comment is not yet specified.
421     *
422     * MCR migration note: this replaced Revision::getComment
423     *
424     * @param int $audience One of:
425     *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
426     *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
427     *   RevisionRecord::RAW              get the text regardless of permissions
428     * @param Authority|null $performer user on whose behalf to check
429     *
430     * @return CommentStoreComment|null
431     */
432    public function getComment( $audience = self::FOR_PUBLIC, Authority $performer = null ) {
433        if ( !$this->audienceCan( self::DELETED_COMMENT, $audience, $performer ) ) {
434            return null;
435        } else {
436            return $this->mComment;
437        }
438    }
439
440    /**
441     * MCR migration note: this replaced Revision::isMinor
442     *
443     * @return bool
444     */
445    public function isMinor() {
446        return (bool)$this->mMinorEdit;
447    }
448
449    /**
450     * MCR migration note: this replaced Revision::isDeleted
451     *
452     * @param int $field One of DELETED_* bitfield constants
453     *
454     * @return bool
455     */
456    public function isDeleted( $field ) {
457        return ( $this->getVisibility() & $field ) == $field;
458    }
459
460    /**
461     * Get the deletion bitfield of the revision
462     *
463     * MCR migration note: this replaced Revision::getVisibility
464     *
465     * @return int
466     */
467    public function getVisibility() {
468        return (int)$this->mDeleted;
469    }
470
471    /**
472     * MCR migration note: this replaced Revision::getTimestamp.
473     *
474     * May return null if the timestamp was not specified.
475     *
476     * @return string|null
477     */
478    public function getTimestamp() {
479        return $this->mTimestamp;
480    }
481
482    /**
483     * Check that the given audience has access to the given field.
484     *
485     * MCR migration note: this corresponded to Revision::userCan
486     *
487     * @param int $field One of self::DELETED_TEXT,
488     *        self::DELETED_COMMENT,
489     *        self::DELETED_USER
490     * @param int $audience One of:
491     *        RevisionRecord::FOR_PUBLIC       to be displayed to all users
492     *        RevisionRecord::FOR_THIS_USER    to be displayed to the given user
493     *        RevisionRecord::RAW              get the text regardless of permissions
494     * @param Authority|null $performer user on whose behalf to check
495     *
496     * @return bool
497     */
498    public function audienceCan( $field, $audience, Authority $performer = null ) {
499        if ( $audience == self::FOR_PUBLIC && $this->isDeleted( $field ) ) {
500            return false;
501        } elseif ( $audience == self::FOR_THIS_USER ) {
502            if ( !$performer ) {
503                throw new InvalidArgumentException(
504                    'An Authority object must be given when checking FOR_THIS_USER audience.'
505                );
506            }
507
508            if ( !$this->userCan( $field, $performer ) ) {
509                return false;
510            }
511        }
512
513        return true;
514    }
515
516    /**
517     * Determine if the give authority is allowed to view a particular
518     * field of this revision, if it's marked as deleted.
519     *
520     * MCR migration note: this corresponded to Revision::userCan
521     *
522     * @param int $field One of self::DELETED_TEXT,
523     *                              self::DELETED_COMMENT,
524     *                              self::DELETED_USER
525     * @param Authority $performer user on whose behalf to check
526     * @return bool
527     */
528    public function userCan( $field, Authority $performer ) {
529        return self::userCanBitfield( $this->getVisibility(), $field, $performer, $this->mPage );
530    }
531
532    /**
533     * Determine if the current user is allowed to view a particular
534     * field of this revision, if it's marked as deleted. This is used
535     * by various classes to avoid duplication.
536     *
537     * MCR migration note: this replaced Revision::userCanBitfield
538     *
539     * @param int $bitfield Current field
540     * @param int $field One of self::DELETED_TEXT = File::DELETED_FILE,
541     *                               self::DELETED_COMMENT = File::DELETED_COMMENT,
542     *                               self::DELETED_USER = File::DELETED_USER
543     * @param Authority $performer user on whose behalf to check
544     * @param PageIdentity|null $page A PageIdentity object to check for per-page restrictions on,
545     *                          instead of just plain user rights
546     * @return bool
547     */
548    public static function userCanBitfield( $bitfield, $field, Authority $performer, PageIdentity $page = null ) {
549        if ( $bitfield & $field ) { // aspect is deleted
550            if ( $bitfield & self::DELETED_RESTRICTED ) {
551                $permissions = [ 'suppressrevision', 'viewsuppressed' ];
552            } elseif ( $field & self::DELETED_TEXT ) {
553                $permissions = [ 'deletedtext' ];
554            } else {
555                $permissions = [ 'deletedhistory' ];
556            }
557
558            $permissionlist = implode( ', ', $permissions );
559            if ( $page === null ) {
560                wfDebug( "Checking for $permissionlist due to $field match on $bitfield" );
561                return $performer->isAllowedAny( ...$permissions );
562            } else {
563                wfDebug( "Checking for $permissionlist on $page due to $field match on $bitfield" );
564                foreach ( $permissions as $perm ) {
565                    if ( $performer->authorizeRead( $perm, $page ) ) {
566                        return true;
567                    }
568                }
569                return false;
570            }
571        } else {
572            return true;
573        }
574    }
575
576    /**
577     * Returns whether this RevisionRecord is ready for insertion, that is, whether it contains all
578     * information needed to save it to the database. This should trivially be true for
579     * RevisionRecords loaded from the database.
580     *
581     * Note that this may return true even if getId() or getPage() return null or 0, since these
582     * are generally assigned while the revision is saved to the database, and may not be available
583     * before.
584     *
585     * @return bool
586     */
587    public function isReadyForInsertion() {
588        // NOTE: don't check getSize() and getSha1(), since that may cause the full content to
589        // be loaded in order to calculate the values. Just assume these methods will not return
590        // null if mSlots is not empty.
591
592        // NOTE: getId() and getPageId() may return null before a revision is saved, so don't
593        // check them.
594
595        return $this->getTimestamp() !== null
596            && $this->getComment( self::RAW ) !== null
597            && $this->getUser( self::RAW ) !== null
598            && $this->mSlots->getSlotRoles() !== [];
599    }
600
601    /**
602     * Checks whether the revision record is a stored current revision.
603     * @since 1.35
604     * @return bool
605     */
606    public function isCurrent() {
607        return false;
608    }
609}