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