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