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