1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace Wikibase\Repo\EditEntity; |
4
|
|
|
|
5
|
|
|
use InvalidArgumentException; |
6
|
|
|
use MediaWiki\MediaWikiServices; |
7
|
|
|
use MWException; |
8
|
|
|
use ReadOnlyError; |
9
|
|
|
use RuntimeException; |
10
|
|
|
use Status; |
11
|
|
|
use Title; |
12
|
|
|
use User; |
13
|
|
|
use Wikibase\DataModel\Entity\EntityDocument; |
14
|
|
|
use Wikibase\DataModel\Entity\EntityId; |
15
|
|
|
use Wikibase\DataModel\Services\Diff\EntityDiffer; |
16
|
|
|
use Wikibase\DataModel\Services\Diff\EntityPatcher; |
17
|
|
|
use Wikibase\Lib\Store\EntityContentTooBigException; |
18
|
|
|
use Wikibase\Lib\Store\EntityRevision; |
19
|
|
|
use Wikibase\Lib\Store\EntityRevisionLookup; |
20
|
|
|
use Wikibase\Lib\Store\EntityStore; |
21
|
|
|
use Wikibase\Lib\Store\LookupConstants; |
22
|
|
|
use Wikibase\Lib\Store\RevisionedUnresolvedRedirectException; |
23
|
|
|
use Wikibase\Lib\Store\StorageException; |
24
|
|
|
use Wikibase\Repo\Store\EntityPermissionChecker; |
25
|
|
|
use Wikibase\Repo\Store\EntityTitleStoreLookup; |
26
|
|
|
use Wikibase\Repo\WikibaseRepo; |
27
|
|
|
|
28
|
|
|
/** |
29
|
|
|
* Handler for editing activity, providing a unified interface for saving modified entities while performing |
30
|
|
|
* permission checks and handling edit conflicts. |
31
|
|
|
* |
32
|
|
|
* @license GPL-2.0-or-later |
33
|
|
|
* @author John Erling Blad < [email protected] > |
34
|
|
|
* @author Daniel Kinzler |
35
|
|
|
* @author Thiemo Kreuz |
36
|
|
|
*/ |
37
|
|
|
class MediawikiEditEntity implements EditEntity { |
38
|
|
|
|
39
|
|
|
/** |
40
|
|
|
* @var EntityRevisionLookup |
41
|
|
|
*/ |
42
|
|
|
private $entityRevisionLookup; |
43
|
|
|
|
44
|
|
|
/** |
45
|
|
|
* @var EntityTitleStoreLookup |
46
|
|
|
*/ |
47
|
|
|
private $titleLookup; |
48
|
|
|
|
49
|
|
|
/** |
50
|
|
|
* @var EntityStore |
51
|
|
|
*/ |
52
|
|
|
private $entityStore; |
53
|
|
|
|
54
|
|
|
/** |
55
|
|
|
* @var EntityPermissionChecker |
56
|
|
|
*/ |
57
|
|
|
private $permissionChecker; |
58
|
|
|
|
59
|
|
|
/** |
60
|
|
|
* @var EntityDiffer |
61
|
|
|
*/ |
62
|
|
|
private $entityDiffer; |
63
|
|
|
|
64
|
|
|
/** |
65
|
|
|
* @var EntityPatcher |
66
|
|
|
*/ |
67
|
|
|
private $entityPatcher; |
68
|
|
|
|
69
|
|
|
/** |
70
|
|
|
* The ID of the entity to edit. May be null if a new entity is being created. |
71
|
|
|
* |
72
|
|
|
* @var EntityId|null |
73
|
|
|
*/ |
74
|
|
|
private $entityId = null; |
75
|
|
|
|
76
|
|
|
/** |
77
|
|
|
* @var EntityRevision|null |
78
|
|
|
*/ |
79
|
|
|
private $baseRev = null; |
80
|
|
|
|
81
|
|
|
/** |
82
|
|
|
* @var int|bool |
83
|
|
|
*/ |
84
|
|
|
private $baseRevId; |
85
|
|
|
|
86
|
|
|
/** |
87
|
|
|
* @var EntityRevision|null |
88
|
|
|
*/ |
89
|
|
|
private $latestRev = null; |
90
|
|
|
|
91
|
|
|
/** |
92
|
|
|
* @var int |
93
|
|
|
*/ |
94
|
|
|
private $latestRevId = 0; |
95
|
|
|
|
96
|
|
|
/** |
97
|
|
|
* @var Status|null |
98
|
|
|
*/ |
99
|
|
|
private $status = null; |
100
|
|
|
|
101
|
|
|
/** |
102
|
|
|
* @var User|null |
103
|
|
|
*/ |
104
|
|
|
private $user = null; |
105
|
|
|
|
106
|
|
|
/** |
107
|
|
|
* @var Title|null |
108
|
|
|
*/ |
109
|
|
|
private $title = null; |
110
|
|
|
|
111
|
|
|
/** |
112
|
|
|
* @var EditFilterHookRunner |
113
|
|
|
*/ |
114
|
|
|
private $editFilterHookRunner; |
115
|
|
|
|
116
|
|
|
/** |
117
|
|
|
* @var int Bit field for error types, using the EditEntity::XXX_ERROR constants. |
118
|
|
|
*/ |
119
|
|
|
private $errorType = 0; |
120
|
|
|
|
121
|
|
|
/** |
122
|
|
|
* @var int |
123
|
|
|
*/ |
124
|
|
|
private $maxSerializedEntitySize; |
125
|
|
|
|
126
|
|
|
/** |
127
|
|
|
* @var bool Can use a master connection or not |
128
|
|
|
*/ |
129
|
|
|
private $allowMasterConnection; |
130
|
|
|
|
131
|
|
|
/** |
132
|
|
|
* @param EntityTitleStoreLookup $titleLookup |
133
|
|
|
* @param EntityRevisionLookup $entityLookup |
134
|
|
|
* @param EntityStore $entityStore |
135
|
|
|
* @param EntityPermissionChecker $permissionChecker |
136
|
|
|
* @param EntityDiffer $entityDiffer |
137
|
|
|
* @param EntityPatcher $entityPatcher |
138
|
|
|
* @param EntityId|null $entityId the ID of the entity being edited. |
139
|
|
|
* May be null when creating a new entity. |
140
|
|
|
* @param User $user the user performing the edit |
141
|
|
|
* @param EditFilterHookRunner $editFilterHookRunner |
142
|
|
|
* @param int $maxSerializedEntitySize the maximal allowed entity size in Kilobytes |
143
|
|
|
* @param int $baseRevId the base revision ID for conflict checking. |
144
|
|
|
* Use 0 to indicate that the current revision should be used as the base revision, |
145
|
|
|
* effectively disabling conflict detections. true and false will be accepted for |
146
|
|
|
* backwards compatibility, but both will be treated like 0. Note that the behavior |
147
|
|
|
* of this class changed so that "late" conflicts that arise between edit conflict |
148
|
|
|
* detection and database update are always detected, and result in the update to fail. |
149
|
|
|
* @param bool $allowMasterConnection |
150
|
|
|
*/ |
151
|
|
|
public function __construct( |
152
|
|
|
EntityTitleStoreLookup $titleLookup, |
153
|
|
|
EntityRevisionLookup $entityLookup, |
154
|
|
|
EntityStore $entityStore, |
155
|
|
|
EntityPermissionChecker $permissionChecker, |
156
|
|
|
EntityDiffer $entityDiffer, |
157
|
|
|
EntityPatcher $entityPatcher, |
158
|
|
|
?EntityId $entityId, |
159
|
|
|
User $user, |
160
|
|
|
EditFilterHookRunner $editFilterHookRunner, |
161
|
|
|
$maxSerializedEntitySize, |
162
|
|
|
$baseRevId = 0, |
163
|
|
|
$allowMasterConnection = true |
164
|
|
|
) { |
165
|
|
|
$this->entityId = $entityId; |
166
|
|
|
|
167
|
|
|
if ( is_string( $baseRevId ) ) { |
168
|
|
|
$baseRevId = (int)$baseRevId; |
169
|
|
|
} |
170
|
|
|
|
171
|
|
|
if ( is_bool( $baseRevId ) ) { |
172
|
|
|
$baseRevId = 0; |
173
|
|
|
} |
174
|
|
|
|
175
|
|
|
$this->user = $user; |
176
|
|
|
$this->baseRevId = $baseRevId; |
177
|
|
|
|
178
|
|
|
$this->errorType = 0; |
179
|
|
|
$this->status = Status::newGood(); |
180
|
|
|
|
181
|
|
|
$this->titleLookup = $titleLookup; |
182
|
|
|
$this->entityRevisionLookup = $entityLookup; |
183
|
|
|
$this->entityStore = $entityStore; |
184
|
|
|
$this->permissionChecker = $permissionChecker; |
185
|
|
|
$this->entityDiffer = $entityDiffer; |
186
|
|
|
$this->entityPatcher = $entityPatcher; |
187
|
|
|
|
188
|
|
|
$this->editFilterHookRunner = $editFilterHookRunner; |
189
|
|
|
$this->allowMasterConnection = $allowMasterConnection; |
190
|
|
|
$this->maxSerializedEntitySize = $maxSerializedEntitySize; |
191
|
|
|
} |
192
|
|
|
|
193
|
|
|
/** |
194
|
|
|
* Returns the ID of the entity being edited. |
195
|
|
|
* May be null if a new entity is to be created. |
196
|
|
|
* |
197
|
|
|
* @return null|EntityId |
198
|
|
|
*/ |
199
|
|
|
public function getEntityId() { |
200
|
|
|
return $this->entityId; |
201
|
|
|
} |
202
|
|
|
|
203
|
|
|
/** |
204
|
|
|
* Returns the Title of the page holding the entity that is being edited. |
205
|
|
|
* |
206
|
|
|
* @return Title|null |
207
|
|
|
*/ |
208
|
|
|
private function getTitle() { |
209
|
|
|
if ( $this->title === null ) { |
210
|
|
|
$id = $this->getEntityId(); |
211
|
|
|
|
212
|
|
|
if ( $id !== null ) { |
213
|
|
|
$this->title = $this->titleLookup->getTitleForId( $id ); |
214
|
|
|
} |
215
|
|
|
} |
216
|
|
|
|
217
|
|
|
return $this->title; |
218
|
|
|
} |
219
|
|
|
|
220
|
|
|
/** |
221
|
|
|
* Returns the latest revision of the entity. |
222
|
|
|
* |
223
|
|
|
* @return EntityRevision|null |
224
|
|
|
*/ |
225
|
|
|
public function getLatestRevision() { |
226
|
|
|
if ( $this->latestRev === null ) { |
227
|
|
|
$id = $this->getEntityId(); |
228
|
|
|
|
229
|
|
|
if ( $id !== null ) { |
230
|
|
|
// NOTE: It's important to remember this, if someone calls clear() on |
231
|
|
|
// $this->getPage(), this should NOT change! |
232
|
|
|
$this->latestRev = $this->entityRevisionLookup->getEntityRevision( |
233
|
|
|
$id, |
234
|
|
|
0, |
235
|
|
|
$this->getReplicaMode() |
236
|
|
|
); |
237
|
|
|
} |
238
|
|
|
} |
239
|
|
|
|
240
|
|
|
return $this->latestRev; |
241
|
|
|
} |
242
|
|
|
|
243
|
|
|
/** |
244
|
|
|
* @return int 0 if the entity doesn't exist |
245
|
|
|
*/ |
246
|
|
|
private function getLatestRevisionId() { |
247
|
|
|
// Don't do negative caching: We call this to see whether the entity yet exists |
248
|
|
|
// before creating. |
249
|
|
|
if ( $this->latestRevId === 0 ) { |
250
|
|
|
$id = $this->getEntityId(); |
251
|
|
|
|
252
|
|
|
if ( $this->latestRev !== null ) { |
253
|
|
|
$this->latestRevId = $this->latestRev->getRevisionId(); |
254
|
|
|
} elseif ( $id !== null ) { |
255
|
|
|
$result = $this->entityRevisionLookup->getLatestRevisionId( |
256
|
|
|
$id, |
257
|
|
|
$this->getReplicaMode() |
258
|
|
|
); |
259
|
|
|
$returnZero = function () { |
260
|
|
|
return 0; |
261
|
|
|
}; |
262
|
|
|
$this->latestRevId = $result->onNonexistentEntity( $returnZero ) |
263
|
|
|
->onRedirect( $returnZero ) |
264
|
|
|
->onConcreteRevision( 'intval' ) |
265
|
|
|
->map(); |
266
|
|
|
} |
267
|
|
|
} |
268
|
|
|
|
269
|
|
|
return $this->latestRevId; |
270
|
|
|
} |
271
|
|
|
|
272
|
|
|
/** |
273
|
|
|
* Is the entity new? |
274
|
|
|
* |
275
|
|
|
* @return bool |
276
|
|
|
*/ |
277
|
|
|
private function isNew() { |
278
|
|
|
return $this->getEntityId() === null || $this->getLatestRevisionId() === 0; |
279
|
|
|
} |
280
|
|
|
|
281
|
|
|
/** |
282
|
|
|
* Does this entity belong to a new page? |
283
|
|
|
* (An entity may {@link isNew be new}, and yet not belong to a new page, |
284
|
|
|
* e. g. if it is stored in a non-main slot.) |
285
|
|
|
* |
286
|
|
|
* @return bool |
287
|
|
|
*/ |
288
|
|
|
private function isNewPage(): bool { |
289
|
|
|
$title = $this->getTitle(); |
290
|
|
|
if ( $title !== null ) { |
291
|
|
|
return !$title->exists(); |
292
|
|
|
} |
293
|
|
|
return true; |
294
|
|
|
} |
295
|
|
|
|
296
|
|
|
/** |
297
|
|
|
* Return the ID of the base revision for the edit. If no base revision ID was supplied to |
298
|
|
|
* the constructor, this returns the ID of the latest revision. If the entity does not exist |
299
|
|
|
* yet, this returns 0. |
300
|
|
|
* |
301
|
|
|
* @return int |
302
|
|
|
*/ |
303
|
|
|
private function getBaseRevisionId() { |
304
|
|
|
if ( $this->baseRevId === 0 ) { |
305
|
|
|
$this->baseRevId = $this->getLatestRevisionId(); |
306
|
|
|
} |
307
|
|
|
|
308
|
|
|
return $this->baseRevId; |
309
|
|
|
} |
310
|
|
|
|
311
|
|
|
/** |
312
|
|
|
* Return the base revision for the edit. If no base revision ID was supplied to |
313
|
|
|
* the constructor, this returns the latest revision. If the entity does not exist |
314
|
|
|
* yet, this returns null. |
315
|
|
|
* |
316
|
|
|
* @return EntityRevision|null |
317
|
|
|
* @throws MWException |
318
|
|
|
*/ |
319
|
|
|
public function getBaseRevision() { |
320
|
|
|
if ( $this->baseRev === null ) { |
321
|
|
|
$baseRevId = $this->getBaseRevisionId(); |
322
|
|
|
|
323
|
|
|
if ( $baseRevId === $this->getLatestRevisionId() ) { |
324
|
|
|
$this->baseRev = $this->getLatestRevision(); |
325
|
|
|
} else { |
326
|
|
|
$id = $this->getEntityId(); |
327
|
|
|
|
328
|
|
|
$this->baseRev = $this->entityRevisionLookup->getEntityRevision( |
329
|
|
|
$id, |
|
|
|
|
330
|
|
|
$baseRevId, |
|
|
|
|
331
|
|
|
$this->getReplicaMode() |
332
|
|
|
); |
333
|
|
|
|
334
|
|
|
if ( $this->baseRev === null ) { |
335
|
|
|
throw new MWException( 'Base revision ID not found: rev ' . $baseRevId |
336
|
|
|
. ' of ' . $id->getSerialization() ); |
337
|
|
|
} |
338
|
|
|
} |
339
|
|
|
} |
340
|
|
|
|
341
|
|
|
return $this->baseRev; |
342
|
|
|
} |
343
|
|
|
|
344
|
|
|
/** |
345
|
|
|
* @return string |
346
|
|
|
*/ |
347
|
|
|
private function getReplicaMode() { |
348
|
|
|
if ( $this->allowMasterConnection === true ) { |
349
|
|
|
return LookupConstants::LATEST_FROM_REPLICA_WITH_FALLBACK; |
350
|
|
|
} else { |
351
|
|
|
return LookupConstants::LATEST_FROM_REPLICA; |
352
|
|
|
} |
353
|
|
|
} |
354
|
|
|
|
355
|
|
|
/** |
356
|
|
|
* Get the status object. Only defined after attemptSave() was called. |
357
|
|
|
* |
358
|
|
|
* After a successful save, the Status object's value field will contain an array, |
359
|
|
|
* just like the status returned by WikiPage::doEditContent(). Well known fields |
360
|
|
|
* in the status value are: |
361
|
|
|
* |
362
|
|
|
* - new: bool whether the edit created a new page |
363
|
|
|
* - revision: Revision the new revision object |
364
|
|
|
* - errorFlags: bit field indicating errors, see the XXX_ERROR constants. |
365
|
|
|
* |
366
|
|
|
* @return Status |
367
|
|
|
*/ |
368
|
|
|
public function getStatus() { |
369
|
|
|
if ( $this->status === null ) { |
370
|
|
|
throw new RuntimeException( 'The status is undefined until attemptSave() has been called' ); |
371
|
|
|
} |
372
|
|
|
|
373
|
|
|
return $this->status; |
374
|
|
|
} |
375
|
|
|
|
376
|
|
|
/** |
377
|
|
|
* Determines whether the last call to attemptSave was successful. |
378
|
|
|
* |
379
|
|
|
* @return bool false if attemptSave() failed, true otherwise |
380
|
|
|
*/ |
381
|
|
|
public function isSuccess() { |
382
|
|
|
return $this->errorType === 0 && $this->status->isOK(); |
383
|
|
|
} |
384
|
|
|
|
385
|
|
|
/** |
386
|
|
|
* Checks whether this EditEntity encountered any of the given error types while executing attemptSave(). |
387
|
|
|
* |
388
|
|
|
* @param int $errorType bit field using the EditEntity::XXX_ERROR constants. |
389
|
|
|
* Defaults to EditEntity::ANY_ERROR. |
390
|
|
|
* |
391
|
|
|
* @return bool true if this EditEntity encountered any of the error types in $errorType, false otherwise. |
392
|
|
|
*/ |
393
|
|
|
public function hasError( $errorType = EditEntity::ANY_ERROR ) { |
394
|
|
|
return ( $this->errorType & $errorType ) !== 0; |
395
|
|
|
} |
396
|
|
|
|
397
|
|
|
/** |
398
|
|
|
* Determines whether an edit conflict exists, that is, whether another user has edited the |
399
|
|
|
* same item after the base revision was created. In other words, this method checks whether |
400
|
|
|
* the base revision (as provided to the constructor) is still current. If no base revision |
401
|
|
|
* was provided to the constructor, this will always return false. |
402
|
|
|
* |
403
|
|
|
* If the base revision is different from the current revision, this will return true even if |
404
|
|
|
* the edit conflict is resolvable. Indeed, it is used to determine whether conflict resolution |
405
|
|
|
* should be attempted. |
406
|
|
|
* |
407
|
|
|
* @return bool |
408
|
|
|
*/ |
409
|
|
|
public function hasEditConflict() { |
410
|
|
|
return !$this->isNew() |
411
|
|
|
&& $this->getBaseRevisionId() !== $this->getLatestRevisionId(); |
412
|
|
|
} |
413
|
|
|
|
414
|
|
|
/** |
415
|
|
|
* Attempts to fix an edit conflict by patching the intended change into the latest revision after |
416
|
|
|
* checking for conflicts. |
417
|
|
|
* |
418
|
|
|
* @param EntityDocument $newEntity |
419
|
|
|
* |
420
|
|
|
* @throws MWException |
421
|
|
|
* @return null|EntityDocument The patched Entity, or null if patching failed. |
422
|
|
|
*/ |
423
|
|
|
private function fixEditConflict( EntityDocument $newEntity ) { |
424
|
|
|
$baseRev = $this->getBaseRevision(); |
425
|
|
|
$latestRev = $this->getLatestRevision(); |
426
|
|
|
|
427
|
|
|
if ( !$latestRev ) { |
428
|
|
|
wfLogWarning( 'Failed to load latest revision of entity ' . $newEntity->getId() . '!' ); |
429
|
|
|
return null; |
430
|
|
|
} |
431
|
|
|
|
432
|
|
|
// calculate patch against base revision |
433
|
|
|
// NOTE: will fail if $baseRev or $base are null, which they may be if |
434
|
|
|
// this gets called at an inappropriate time. The data flow in this class |
435
|
|
|
// should be improved. |
436
|
|
|
$patch = $this->entityDiffer->diffEntities( $baseRev->getEntity(), $newEntity ); |
437
|
|
|
|
438
|
|
|
if ( $patch->isEmpty() ) { |
439
|
|
|
// we didn't technically fix anything, but if there is nothing to change, |
440
|
|
|
// so just keep the current content as it is. |
441
|
|
|
return $latestRev->getEntity()->copy(); |
442
|
|
|
} |
443
|
|
|
|
444
|
|
|
// apply the patch( base -> new ) to the latest revision. |
445
|
|
|
$patchedLatest = $latestRev->getEntity()->copy(); |
446
|
|
|
$this->entityPatcher->patchEntity( $patchedLatest, $patch ); |
447
|
|
|
|
448
|
|
|
// detect conflicts against latest revision |
449
|
|
|
$cleanPatch = $this->entityDiffer->diffEntities( $latestRev->getEntity(), $patchedLatest ); |
450
|
|
|
|
451
|
|
|
$conflicts = $patch->count() - $cleanPatch->count(); |
452
|
|
|
|
453
|
|
|
if ( $conflicts !== 0 ) { |
454
|
|
|
// patch doesn't apply cleanly |
455
|
|
|
if ( $this->userWasLastToEdit( $this->user, $newEntity->getId(), $this->getBaseRevisionId() ) ) { |
456
|
|
|
// it's a self-conflict |
457
|
|
|
if ( $cleanPatch->count() === 0 ) { |
458
|
|
|
// patch collapsed, possibly because of diff operation change from base to latest |
459
|
|
|
return null; |
460
|
|
|
} else { |
461
|
|
|
// we still have a working patch, try to apply |
462
|
|
|
$this->status->warning( 'wikibase-self-conflict-patched' ); |
463
|
|
|
} |
464
|
|
|
} else { |
465
|
|
|
// there are unresolvable conflicts. |
466
|
|
|
return null; |
467
|
|
|
} |
468
|
|
|
} else { |
469
|
|
|
// can apply cleanly |
470
|
|
|
$this->status->warning( 'wikibase-conflict-patched' ); |
471
|
|
|
} |
472
|
|
|
|
473
|
|
|
// return the patched entity |
474
|
|
|
return $patchedLatest; |
475
|
|
|
} |
476
|
|
|
|
477
|
|
|
/** |
478
|
|
|
* Check if no edits were made by other users since the given revision. |
479
|
|
|
* This makes the assumption that revision ids are monotonically increasing. |
480
|
|
|
* |
481
|
|
|
* @param User|null $user |
482
|
|
|
* @param EntityId|null $entityId |
483
|
|
|
* @param int|bool $lastRevId |
484
|
|
|
* |
485
|
|
|
* @return bool |
486
|
|
|
*/ |
487
|
|
|
private function userWasLastToEdit( User $user = null, EntityId $entityId = null, $lastRevId = false ) { |
488
|
|
|
if ( $user === null || $entityId === null || $lastRevId === false ) { |
489
|
|
|
return false; |
490
|
|
|
} |
491
|
|
|
|
492
|
|
|
return $this->entityStore->userWasLastToEdit( $user, $entityId, $lastRevId ); |
|
|
|
|
493
|
|
|
} |
494
|
|
|
|
495
|
|
|
/** |
496
|
|
|
* Checks the necessary permissions to perform this edit. |
497
|
|
|
* Per default, the 'edit' permission is checked. |
498
|
|
|
* Use addRequiredPermission() to check more permissions. |
499
|
|
|
* |
500
|
|
|
* @param EntityDocument $newEntity |
501
|
|
|
*/ |
502
|
|
|
private function checkEditPermissions( EntityDocument $newEntity ) { |
503
|
|
|
$permissionStatus = $this->permissionChecker->getPermissionStatusForEntity( |
504
|
|
|
$this->user, |
|
|
|
|
505
|
|
|
EntityPermissionChecker::ACTION_EDIT, |
506
|
|
|
$newEntity |
507
|
|
|
); |
508
|
|
|
|
509
|
|
|
$this->status->merge( $permissionStatus ); |
510
|
|
|
|
511
|
|
|
if ( !$this->status->isOK() ) { |
512
|
|
|
$this->errorType |= EditEntity::PERMISSION_ERROR; |
513
|
|
|
$this->status->fatal( 'permissionserrors' ); |
514
|
|
|
} |
515
|
|
|
} |
516
|
|
|
|
517
|
|
|
/** |
518
|
|
|
* Checks if rate limits have been exceeded. |
519
|
|
|
*/ |
520
|
|
|
private function checkRateLimits() { |
521
|
|
|
if ( $this->user->pingLimiter( 'edit' ) |
522
|
|
|
|| ( $this->isNew() && $this->user->pingLimiter( 'create' ) ) |
523
|
|
|
) { |
524
|
|
|
$this->errorType |= EditEntity::RATE_LIMIT; |
525
|
|
|
$this->status->fatal( 'actionthrottledtext' ); |
526
|
|
|
} |
527
|
|
|
} |
528
|
|
|
|
529
|
|
|
/** |
530
|
|
|
* Make sure the given WebRequest contains a valid edit token. |
531
|
|
|
* |
532
|
|
|
* @param string $token The token to check. |
533
|
|
|
* |
534
|
|
|
* @return bool true if the token is valid |
535
|
|
|
*/ |
536
|
|
|
public function isTokenOK( $token ) { |
537
|
|
|
$tokenOk = $this->user->matchEditToken( $token ); |
538
|
|
|
$tokenOkExceptSuffix = $this->user->matchEditTokenNoSuffix( $token ); |
539
|
|
|
|
540
|
|
|
if ( !$tokenOk ) { |
541
|
|
|
if ( $tokenOkExceptSuffix ) { |
542
|
|
|
$this->status->fatal( 'token_suffix_mismatch' ); |
543
|
|
|
} else { |
544
|
|
|
$this->status->fatal( 'session_fail_preview' ); |
545
|
|
|
} |
546
|
|
|
|
547
|
|
|
$this->errorType |= EditEntity::TOKEN_ERROR; |
548
|
|
|
return false; |
549
|
|
|
} |
550
|
|
|
|
551
|
|
|
return true; |
552
|
|
|
} |
553
|
|
|
|
554
|
|
|
/** |
555
|
|
|
* Resolve user specific default default for watch state, if $watch is null. |
556
|
|
|
* |
557
|
|
|
* @param boolean|null $watch |
558
|
|
|
* |
559
|
|
|
* @return bool |
560
|
|
|
*/ |
561
|
|
|
private function getDesiredWatchState( $watch ) { |
562
|
|
|
if ( $watch === null ) { |
563
|
|
|
$watch = $this->getWatchDefault(); |
564
|
|
|
} |
565
|
|
|
|
566
|
|
|
return $watch; |
567
|
|
|
} |
568
|
|
|
|
569
|
|
|
/** |
570
|
|
|
* @param EntityId|null $id |
571
|
|
|
* |
572
|
|
|
* @throws InvalidArgumentException |
573
|
|
|
*/ |
574
|
|
|
private function checkEntityId( EntityId $id = null ) { |
575
|
|
|
if ( $this->entityId ) { |
576
|
|
|
if ( !$this->entityId->equals( $id ) ) { |
577
|
|
|
throw new InvalidArgumentException( |
578
|
|
|
'Expected the EntityDocument to have ID ' . $this->entityId->getSerialization() |
579
|
|
|
. ', found ' . ( $id ? $id->getSerialization() : 'null' ) |
580
|
|
|
); |
581
|
|
|
} |
582
|
|
|
} |
583
|
|
|
} |
584
|
|
|
|
585
|
|
|
/** |
586
|
|
|
* @param EntityDocument $entity |
587
|
|
|
* |
588
|
|
|
* @throws ReadOnlyError |
589
|
|
|
*/ |
590
|
|
|
private function checkReadOnly( EntityDocument $entity ) { |
591
|
|
|
if ( wfReadOnly() ) { |
592
|
|
|
throw new ReadOnlyError(); |
593
|
|
|
} |
594
|
|
|
if ( $this->entityTypeIsReadOnly( $entity ) ) { |
595
|
|
|
MediaWikiServices::getInstance()->getConfiguredReadOnlyMode()->setReason( |
596
|
|
|
'Editing of entity type: ' . $entity->getType() . ' is currently disabled. It will be enabled soon.' |
597
|
|
|
); |
598
|
|
|
throw new ReadOnlyError(); |
599
|
|
|
} |
600
|
|
|
} |
601
|
|
|
|
602
|
|
|
/** |
603
|
|
|
* @param EntityDocument $entity |
604
|
|
|
* @return bool |
605
|
|
|
*/ |
606
|
|
|
private function entityTypeIsReadOnly( EntityDocument $entity ) { |
607
|
|
|
$readOnlyTypes = WikibaseRepo::getDefaultInstance()->getSettings()->getSetting( 'readOnlyEntityTypes' ); |
608
|
|
|
|
609
|
|
|
return in_array( $entity->getType(), $readOnlyTypes ); |
610
|
|
|
} |
611
|
|
|
|
612
|
|
|
/** |
613
|
|
|
* Attempts to save the given Entity object. |
614
|
|
|
* |
615
|
|
|
* This method performs entity level permission checks, checks the edit toke, enforces rate |
616
|
|
|
* limits, resolves edit conflicts, and updates user watchlists if appropriate. |
617
|
|
|
* |
618
|
|
|
* Success or failure are reported via the Status object returned by this method. |
619
|
|
|
* |
620
|
|
|
* @param EntityDocument $newEntity |
621
|
|
|
* @param string $summary The edit summary. |
622
|
|
|
* @param int $flags The EDIT_XXX flags as used by WikiPage::doEditContent(). |
623
|
|
|
* Additionally, the EntityContent::EDIT_XXX constants can be used. |
624
|
|
|
* @param string|bool $token Edit token to check, or false to disable the token check. |
625
|
|
|
* Null will fail the token text, as will the empty string. |
626
|
|
|
* @param bool|null $watch Whether the user wants to watch the entity. |
627
|
|
|
* Set to null to apply default according to getWatchDefault(). |
628
|
|
|
* @param string[] $tags Change tags to add to the edit. |
629
|
|
|
* Callers are responsible for checking that the user is permitted to add these tags |
630
|
|
|
* (typically using {@link ChangeTags::canAddTagsAccompanyingChange}). |
631
|
|
|
* |
632
|
|
|
* @return Status |
633
|
|
|
* |
634
|
|
|
* @throws MWException |
635
|
|
|
* @throws ReadOnlyError |
636
|
|
|
* |
637
|
|
|
* @see WikiPage::doEditContent |
638
|
|
|
* @see EntityStore::saveEntity |
639
|
|
|
*/ |
640
|
|
|
public function attemptSave( EntityDocument $newEntity, $summary, $flags, $token, $watch = null, array $tags = [] ) { |
641
|
|
|
$this->checkReadOnly( $newEntity ); |
642
|
|
|
$this->checkEntityId( $newEntity->getId() ); |
643
|
|
|
|
644
|
|
|
$watch = $this->getDesiredWatchState( $watch ); |
645
|
|
|
|
646
|
|
|
$this->status = Status::newGood(); |
647
|
|
|
$this->errorType = 0; |
648
|
|
|
|
649
|
|
|
if ( $token !== false && !$this->isTokenOK( $token ) ) { |
|
|
|
|
650
|
|
|
//@todo: This is redundant to the error code set in isTokenOK(). |
651
|
|
|
// We should figure out which error codes the callers expect, |
652
|
|
|
// and only set the correct error code, in one place, probably here. |
653
|
|
|
$this->errorType |= EditEntity::TOKEN_ERROR; |
654
|
|
|
$this->status->fatal( 'sessionfailure' ); |
655
|
|
|
$this->status->setResult( false, [ 'errorFlags' => $this->errorType ] ); |
656
|
|
|
return $this->status; |
657
|
|
|
} |
658
|
|
|
|
659
|
|
|
$this->checkEditPermissions( $newEntity ); |
660
|
|
|
|
661
|
|
|
$this->checkRateLimits(); // modifies $this->status |
662
|
|
|
|
663
|
|
|
if ( !$this->status->isOK() ) { |
664
|
|
|
$this->status->setResult( false, [ 'errorFlags' => $this->errorType ] ); |
665
|
|
|
return $this->status; |
666
|
|
|
} |
667
|
|
|
|
668
|
|
|
// NOTE: Make sure the latest revision is loaded and cached. |
669
|
|
|
// Would happen on demand anyway, but we want a well-defined point at which "latest" is |
670
|
|
|
// frozen to a specific revision, just before the first check for edit conflicts. |
671
|
|
|
// We can use the ID of the latest revision to protect against race conditions: |
672
|
|
|
// if getLatestRevision() was called earlier by application logic, saving will fail |
673
|
|
|
// if any new revisions were created between then and now. |
674
|
|
|
// Note that this protection against "late" conflicts is unrelated to the detection |
675
|
|
|
// of edit conflicts during user interaction, which use the base revision supplied |
676
|
|
|
// to the constructor. |
677
|
|
|
try { |
678
|
|
|
$this->getLatestRevision(); |
679
|
|
|
} catch ( RevisionedUnresolvedRedirectException $exception ) { |
680
|
|
|
$this->errorType |= EditEntity::PRECONDITION_FAILED; |
681
|
|
|
$this->status->fatal( |
682
|
|
|
'wikibase-save-unresolved-redirect', |
683
|
|
|
$exception->getEntityId()->getSerialization(), |
684
|
|
|
$exception->getRedirectTargetId()->getSerialization() |
685
|
|
|
); |
686
|
|
|
$this->status->setResult( false, [ 'errorFlags' => $this->errorType ] ); |
687
|
|
|
return $this->status; |
688
|
|
|
} |
689
|
|
|
|
690
|
|
|
$raceProtectionRevId = $this->getLatestRevisionId(); |
691
|
|
|
|
692
|
|
|
if ( $raceProtectionRevId === 0 ) { |
693
|
|
|
$raceProtectionRevId = false; |
694
|
|
|
} |
695
|
|
|
|
696
|
|
|
if ( $this->hasEditConflict() ) { |
697
|
|
|
$newEntity = $this->fixEditConflict( $newEntity ); |
698
|
|
|
|
699
|
|
|
if ( !$newEntity ) { |
700
|
|
|
$this->errorType |= EditEntity::EDIT_CONFLICT_ERROR; |
701
|
|
|
$this->status->setResult( false, [ 'errorFlags' => $this->errorType ] ); |
702
|
|
|
$this->status->error( 'edit-conflict' ); |
703
|
|
|
|
704
|
|
|
return $this->status; |
705
|
|
|
} |
706
|
|
|
} |
707
|
|
|
|
708
|
|
|
if ( !$this->status->isOK() ) { |
709
|
|
|
$this->errorType |= EditEntity::PRECONDITION_FAILED; |
710
|
|
|
} |
711
|
|
|
|
712
|
|
|
if ( !$this->status->isOK() ) { |
713
|
|
|
$this->status->setResult( false, [ 'errorFlags' => $this->errorType ] ); |
714
|
|
|
return $this->status; |
715
|
|
|
} |
716
|
|
|
|
717
|
|
|
try { |
718
|
|
|
$hookStatus = $this->editFilterHookRunner->run( $newEntity, $this->user, $summary ); |
|
|
|
|
719
|
|
|
} catch ( EntityContentTooBigException $ex ) { |
720
|
|
|
$this->status->setResult( false, [ 'errorFlags' => $this->errorType ] ); |
721
|
|
|
$this->status->error( wfMessage( 'wikibase-error-entity-too-big' )->sizeParams( $this->maxSerializedEntitySize * 1024 ) ); |
722
|
|
|
return $this->status; |
723
|
|
|
} |
724
|
|
|
if ( !$hookStatus->isOK() ) { |
725
|
|
|
$this->errorType |= EditEntity::FILTERED; |
726
|
|
|
} |
727
|
|
|
$this->status->merge( $hookStatus ); |
728
|
|
|
|
729
|
|
|
if ( !$this->status->isOK() ) { |
730
|
|
|
$this->status->setResult( false, [ 'errorFlags' => $this->errorType ] ); |
731
|
|
|
return $this->status; |
732
|
|
|
} |
733
|
|
|
|
734
|
|
|
try { |
735
|
|
|
$entityRevision = $this->entityStore->saveEntity( |
736
|
|
|
$newEntity, |
737
|
|
|
$summary, |
738
|
|
|
$this->user, |
|
|
|
|
739
|
|
|
$flags | EDIT_AUTOSUMMARY, |
740
|
|
|
$raceProtectionRevId, |
741
|
|
|
$tags |
742
|
|
|
); |
743
|
|
|
|
744
|
|
|
$this->entityId = $newEntity->getId(); |
745
|
|
|
$editStatus = Status::newGood( [ 'revision' => $entityRevision ] ); |
746
|
|
|
} catch ( StorageException $ex ) { |
747
|
|
|
$editStatus = $ex->getStatus(); |
748
|
|
|
|
749
|
|
|
if ( $editStatus === null ) { |
750
|
|
|
// XXX: perhaps internalerror_info isn't the best, but we need some generic error message. |
751
|
|
|
$editStatus = Status::newFatal( 'internalerror_info', $ex->getMessage() ); |
752
|
|
|
} |
753
|
|
|
|
754
|
|
|
$this->errorType |= EditEntity::SAVE_ERROR; |
755
|
|
|
} catch ( EntityContentTooBigException $ex ) { |
756
|
|
|
$this->status->setResult( false, [ 'errorFlags' => $this->errorType ] ); |
757
|
|
|
$this->status->error( wfMessage( 'wikibase-error-entity-too-big' )->sizeParams( $this->maxSerializedEntitySize * 1024 ) ); |
758
|
|
|
return $this->status; |
759
|
|
|
} |
760
|
|
|
|
761
|
|
|
$this->status->setResult( $editStatus->isOK(), $editStatus->getValue() ); |
762
|
|
|
$this->status->merge( $editStatus ); |
763
|
|
|
|
764
|
|
|
if ( $this->status->isOK() ) { |
765
|
|
|
$this->updateWatchlist( $watch ); |
766
|
|
|
} else { |
767
|
|
|
$value = $this->status->getValue(); |
768
|
|
|
$value['errorFlags'] = $this->errorType; |
769
|
|
|
$this->status->setResult( false, $value ); |
770
|
|
|
} |
771
|
|
|
|
772
|
|
|
return $this->status; |
773
|
|
|
} |
774
|
|
|
|
775
|
|
|
/** |
776
|
|
|
* Returns whether the present edit would, per default, |
777
|
|
|
* lead to the user watching the page. |
778
|
|
|
* |
779
|
|
|
* This uses the user's watchdefault and watchcreations settings |
780
|
|
|
* and considers whether the entity is already watched by the user. |
781
|
|
|
* |
782
|
|
|
* @note Keep in sync with logic in EditPage! |
783
|
|
|
* |
784
|
|
|
* @return bool |
785
|
|
|
*/ |
786
|
|
|
private function getWatchDefault() { |
787
|
|
|
// User wants to watch all edits or all creations. |
788
|
|
|
if ( $this->user->getOption( 'watchdefault' ) |
789
|
|
|
|| ( $this->user->getOption( 'watchcreations' ) && $this->isNewPage() ) |
790
|
|
|
) { |
791
|
|
|
return true; |
792
|
|
|
} |
793
|
|
|
|
794
|
|
|
// keep current state |
795
|
|
|
return $this->getEntityId() !== null && |
796
|
|
|
$this->entityStore->isWatching( $this->user, $this->getEntityId() ); |
|
|
|
|
797
|
|
|
} |
798
|
|
|
|
799
|
|
|
/** |
800
|
|
|
* Watches or unwatches the entity. |
801
|
|
|
* |
802
|
|
|
* @note Keep in sync with logic in EditPage! |
803
|
|
|
* @todo move to separate service |
804
|
|
|
* |
805
|
|
|
* @param bool $watch whether to watch or unwatch the page. |
806
|
|
|
* |
807
|
|
|
* @throws MWException |
808
|
|
|
*/ |
809
|
|
|
private function updateWatchlist( $watch ) { |
810
|
|
|
if ( $this->getTitle() === null ) { |
811
|
|
|
throw new MWException( 'Title not yet known!' ); |
812
|
|
|
} |
813
|
|
|
|
814
|
|
|
$this->entityStore->updateWatchlist( $this->user, $this->getEntityId(), $watch ); |
|
|
|
|
815
|
|
|
} |
816
|
|
|
|
817
|
|
|
} |
818
|
|
|
|
Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code: