Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
53.65% |
147 / 274 |
|
49.15% |
29 / 59 |
CRAP | |
0.00% |
0 / 1 |
AbstractRevision | |
53.65% |
147 / 274 |
|
49.15% |
29 / 59 |
1706.89 | |
0.00% |
0 / 1 |
fromStorageRow | |
93.33% |
28 / 30 |
|
0.00% |
0 / 1 |
10.03 | |||
toStorageRow | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
9 | |||
newNullRevision | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
2.02 | |||
newNextRevision | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
moderate | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 | |||
isValidModerationState | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getRevisionId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
hasHiddenContent | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getContentRaw | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
isContentCurrentlyRetrievable | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getContent | |
56.67% |
17 / 30 |
|
0.00% |
0 / 1 |
18.14 | |||
getContentInWikitext | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getWikitextFormat | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getContentInHtml | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getHtmlFormat | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUserTuple | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUserId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getUserIp | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getUserWiki | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getUser | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setContent | |
33.33% |
11 / 33 |
|
0.00% |
0 / 1 |
39.63 | |||
setContentRaw | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
2.00 | |||
setNextContent | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
getContentFormat | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getStorageFormat | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPrevRevisionId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getChangeType | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getModerationState | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getModeratedReason | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isModerated | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isHidden | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isDeleted | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isSuppressed | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isLocked | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getModerationTimestamp | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isFlaggedAny | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
isFlaggedAll | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
isFirstRevision | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isOriginalContent | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getLastContentEditId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getLastContentEditUserTuple | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getLastContentEditUserId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
getLastContentEditUserIp | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
getLastContentEditUserWiki | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
getModeratedByTuple | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getModeratedByUserId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getModeratedByUserIp | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getModeratedByUserWiki | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getModerationChangeTypes | |
22.22% |
2 / 9 |
|
0.00% |
0 / 1 |
11.53 | |||
isModerationChange | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getContentLength | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
calculateContentLength | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getPreviousContentLength | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRecentChange | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
56 | |||
getCreatorTuple | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getCreatorId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCreatorWiki | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getCreatorIp | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
hasSameContentAs | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getRevisionType | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getCollectionId | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getCollection | n/a |
0 / 0 |
n/a |
0 / 0 |
0 |
1 | <?php |
2 | |
3 | namespace Flow\Model; |
4 | |
5 | use Flow\Collection\AbstractCollection; |
6 | use Flow\Conversion\Utils; |
7 | use Flow\Exception\DataModelException; |
8 | use Flow\Exception\InvalidDataException; |
9 | use Flow\Exception\PermissionException; |
10 | use Flow\Hooks\HookRunner; |
11 | use MediaWiki\MediaWikiServices; |
12 | use MediaWiki\Parser\Sanitizer; |
13 | use MediaWiki\RecentChanges\RecentChange; |
14 | use MediaWiki\Title\Title; |
15 | use MediaWiki\User\User; |
16 | |
17 | abstract class AbstractRevision { |
18 | public const MODERATED_NONE = ''; |
19 | public const MODERATED_HIDDEN = 'hide'; |
20 | public const MODERATED_DELETED = 'delete'; |
21 | public const MODERATED_SUPPRESSED = 'suppress'; |
22 | public const MODERATED_LOCKED = 'lock'; |
23 | |
24 | /** |
25 | * List of available permission levels. |
26 | * |
27 | * @var string[] |
28 | */ |
29 | public static $perms = [ |
30 | self::MODERATED_NONE, |
31 | self::MODERATED_HIDDEN, |
32 | self::MODERATED_DELETED, |
33 | self::MODERATED_SUPPRESSED, |
34 | self::MODERATED_LOCKED, |
35 | ]; |
36 | |
37 | /** |
38 | * List of moderation change types |
39 | * |
40 | * @var array|null |
41 | */ |
42 | protected static $moderationChangeTypes = null; |
43 | |
44 | /** |
45 | * @var UUID |
46 | */ |
47 | protected $revId; |
48 | |
49 | /** |
50 | * @var UserTuple |
51 | */ |
52 | protected $user; |
53 | |
54 | /** |
55 | * Array of flags strictly related to the content. Flags are reset when |
56 | * content changes. |
57 | * |
58 | * @var string[] |
59 | */ |
60 | protected $flags = []; |
61 | |
62 | /** |
63 | * Name of the action performed that generated this revision. |
64 | * |
65 | * @see FlowActions.php |
66 | * @var string |
67 | */ |
68 | protected $changeType; |
69 | |
70 | /** |
71 | * @var UUID|null The id of the revision prior to this one, or null if this is first revision |
72 | */ |
73 | protected $prevRevision; |
74 | |
75 | /** |
76 | * @var string|null Raw content of revision |
77 | */ |
78 | protected $content; |
79 | |
80 | /** |
81 | * @var string|null Only populated when external store is in use |
82 | */ |
83 | protected $contentUrl; |
84 | |
85 | /** |
86 | * @var string|null This is decompressed on-demand from $this->content in self::getContent() |
87 | */ |
88 | protected $decompressedContent; |
89 | |
90 | /** |
91 | * @var string[] Converted (wikitext|html) content, based off of $this->decompressedContent |
92 | */ |
93 | protected $convertedContent = []; |
94 | |
95 | /** |
96 | * html content has been allowed by the xss check. When we find the next xss |
97 | * in the parser this hook allows preventing any display of hostile html. True |
98 | * means the content is allowed. False means not allowed. Null means unchecked |
99 | * |
100 | * @var bool |
101 | */ |
102 | protected $xssCheck; |
103 | |
104 | /** |
105 | * moderation states for the revision. This is technically denormalized data |
106 | * since it can be overwritten and does not provide a full history. |
107 | * The tricky part is updating moderation is a new revision for hide and |
108 | * delete, but adjusts an existing revision for full suppression. |
109 | * |
110 | * @var string |
111 | */ |
112 | protected $moderationState = self::MODERATED_NONE; |
113 | |
114 | /** |
115 | * @var string|null |
116 | */ |
117 | protected $moderationTimestamp; |
118 | |
119 | /** |
120 | * @var UserTuple|null |
121 | */ |
122 | protected $moderatedBy; |
123 | |
124 | /** |
125 | * @var string|null |
126 | */ |
127 | protected $moderatedReason; |
128 | |
129 | /** |
130 | * @var UUID|null The id of the last content edit revision |
131 | */ |
132 | protected $lastEditId; |
133 | |
134 | /** |
135 | * @var UserTuple|null |
136 | */ |
137 | protected $lastEditUser; |
138 | |
139 | /** |
140 | * @var int Size of previous revision wikitext |
141 | */ |
142 | protected $previousContentLength = 0; |
143 | |
144 | /** |
145 | * @var int Size of current revision wikitext |
146 | */ |
147 | protected $contentLength = 0; |
148 | |
149 | /** |
150 | * Author of the first revision |
151 | * |
152 | * @var UserTuple |
153 | */ |
154 | protected $creator; |
155 | |
156 | /** |
157 | * @param string[] $row |
158 | * @param AbstractRevision|null $obj |
159 | * @return AbstractRevision |
160 | * @throws DataModelException |
161 | */ |
162 | public static function fromStorageRow( array $row, $obj = null ) { |
163 | if ( $obj === null ) { |
164 | /** @var AbstractRevision $obj */ |
165 | $obj = new static; // @phan-suppress-current-line PhanTypeInstantiateAbstractStatic |
166 | } elseif ( !$obj instanceof static ) { |
167 | throw new DataModelException( 'wrong object type', 'process-data' ); |
168 | } |
169 | $obj->revId = UUID::create( $row['rev_id'] ); |
170 | $obj->user = UserTuple::newFromArray( $row, 'rev_user_' ); |
171 | if ( $obj->user === null ) { |
172 | throw new DataModelException( 'Could not load UserTuple for rev_user_' ); |
173 | } |
174 | $obj->prevRevision = $row['rev_parent_id'] ? UUID::create( $row['rev_parent_id'] ) : null; |
175 | $obj->changeType = $row['rev_change_type']; |
176 | $obj->flags = array_filter( explode( ',', $row['rev_flags'] ) ); |
177 | $obj->content = $row['rev_content']; |
178 | // null if external store is not being used |
179 | $obj->contentUrl = $row['rev_content_url'] ?? null; |
180 | $obj->decompressedContent = null; |
181 | |
182 | $obj->moderationState = $row['rev_mod_state']; |
183 | $obj->moderatedBy = UserTuple::newFromArray( $row, 'rev_mod_user_' ); |
184 | $obj->moderationTimestamp = wfTimestampOrNull( TS_MW, $row['rev_mod_timestamp'] ?: null ); |
185 | $obj->moderatedReason = isset( $row['rev_mod_reason'] ) && $row['rev_mod_reason'] |
186 | ? $row['rev_mod_reason'] : null; |
187 | |
188 | // BC: 'suppress' used to be called 'censor' & 'lock' was 'close' |
189 | $bc = [ |
190 | 'censor' => self::MODERATED_SUPPRESSED, |
191 | 'close' => self::MODERATED_LOCKED, |
192 | ]; |
193 | $obj->moderationState = str_replace( array_keys( $bc ), array_values( $bc ), $obj->moderationState ); |
194 | |
195 | // isset required because there is a possible db migration, cached data will not have it |
196 | $obj->lastEditId = isset( $row['rev_last_edit_id'] ) && $row['rev_last_edit_id'] |
197 | ? UUID::create( $row['rev_last_edit_id'] ) : null; |
198 | $obj->lastEditUser = UserTuple::newFromArray( $row, 'rev_edit_user_' ); |
199 | |
200 | $obj->contentLength = $row['rev_content_length'] ?? 0; |
201 | $obj->previousContentLength = $row['rev_previous_content_length'] ?? 0; |
202 | |
203 | return $obj; |
204 | } |
205 | |
206 | /** |
207 | * @param AbstractRevision $obj |
208 | * @return array |
209 | */ |
210 | public static function toStorageRow( $obj ) { |
211 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
212 | return [ |
213 | 'rev_id' => $obj->revId->getAlphadecimal(), |
214 | 'rev_user_id' => $obj->user->id, |
215 | 'rev_user_ip' => $obj->user->ip, |
216 | 'rev_user_wiki' => $obj->user->wiki, |
217 | 'rev_parent_id' => $obj->prevRevision ? $obj->prevRevision->getAlphadecimal() : null, |
218 | 'rev_change_type' => $obj->changeType, |
219 | 'rev_type' => $obj->getRevisionType(), |
220 | 'rev_type_id' => $obj->getCollectionId()->getAlphadecimal(), |
221 | |
222 | 'rev_content' => $obj->content, |
223 | 'rev_content_url' => $obj->contentUrl, |
224 | 'rev_flags' => implode( ',', $obj->flags ), |
225 | |
226 | 'rev_mod_state' => $obj->moderationState, |
227 | 'rev_mod_user_id' => $obj->moderatedBy ? $obj->moderatedBy->id : null, |
228 | 'rev_mod_user_ip' => $obj->moderatedBy ? $obj->moderatedBy->ip : null, |
229 | 'rev_mod_user_wiki' => $obj->moderatedBy ? $obj->moderatedBy->wiki : null, |
230 | 'rev_mod_timestamp' => $dbr->timestampOrNull( $obj->moderationTimestamp ), |
231 | 'rev_mod_reason' => $obj->moderatedReason, |
232 | |
233 | 'rev_last_edit_id' => $obj->lastEditId ? $obj->lastEditId->getAlphadecimal() : null, |
234 | 'rev_edit_user_id' => $obj->lastEditUser ? $obj->lastEditUser->id : null, |
235 | 'rev_edit_user_ip' => $obj->lastEditUser ? $obj->lastEditUser->ip : null, |
236 | 'rev_edit_user_wiki' => $obj->lastEditUser ? $obj->lastEditUser->wiki : null, |
237 | |
238 | 'rev_content_length' => $obj->contentLength, |
239 | 'rev_previous_content_length' => $obj->previousContentLength, |
240 | ]; |
241 | } |
242 | |
243 | /** |
244 | * NOTE: No guarantee is made here regarding if $this is the newest revision. Validation |
245 | * must happen externally. DB *will* throw an exception if this attempts to write to db |
246 | * and it is not the most recent revision. |
247 | * |
248 | * @param User $user |
249 | * @return AbstractRevision |
250 | * @throws PermissionException |
251 | */ |
252 | public function newNullRevision( User $user ) { |
253 | if ( !MediaWikiServices::getInstance()->getPermissionManager() |
254 | ->userHasRight( $user, 'edit' ) |
255 | ) { |
256 | throw new PermissionException( 'User does not have core edit permission', |
257 | 'insufficient-permission' ); |
258 | } |
259 | $obj = clone $this; |
260 | $obj->revId = UUID::create(); |
261 | $obj->user = UserTuple::newFromUser( $user ); |
262 | $obj->prevRevision = $this->revId; |
263 | $obj->changeType = ''; |
264 | $obj->previousContentLength = $obj->contentLength; |
265 | |
266 | return $obj; |
267 | } |
268 | |
269 | /** |
270 | * Create the next revision with new content |
271 | * or return itself when content is the same |
272 | * |
273 | * @param User $user |
274 | * @param string $content |
275 | * @param string $format wikitext|html |
276 | * @param string $changeType |
277 | * @param Title $title The article title of the related workflow |
278 | * @return AbstractRevision |
279 | */ |
280 | public function newNextRevision( User $user, $content, $format, $changeType, Title $title ) { |
281 | $obj = $this->newNullRevision( $user ); |
282 | $obj->setNextContent( $user, $content, $format, $title ); |
283 | $obj->changeType = $changeType; |
284 | return $this->hasSameContentAs( $obj ) ? $this : $obj; |
285 | } |
286 | |
287 | /** |
288 | * @param User $user |
289 | * @param string $state |
290 | * @param string $changeType |
291 | * @param string $reason |
292 | * @return AbstractRevision|null |
293 | */ |
294 | public function moderate( User $user, $state, $changeType, $reason ) { |
295 | if ( !$this->isValidModerationState( $state ) ) { |
296 | wfWarn( __METHOD__ . ': Provided moderation state does not exist : ' . $state ); |
297 | return null; |
298 | } |
299 | |
300 | $obj = $this->newNullRevision( $user ); |
301 | $obj->changeType = $changeType; |
302 | |
303 | // This is a bit hacky, but we store the restore reason |
304 | // in the "moderated reason" field. Hmmph. |
305 | $obj->moderatedReason = $reason; |
306 | $obj->moderationState = $state; |
307 | |
308 | if ( $state === self::MODERATED_NONE ) { |
309 | $obj->moderatedBy = null; |
310 | $obj->moderationTimestamp = null; |
311 | } else { |
312 | $obj->moderatedBy = UserTuple::newFromUser( $user ); |
313 | $obj->moderationTimestamp = $obj->revId->getTimestamp(); |
314 | } |
315 | |
316 | // all moderation levels past lock report a size of 0 |
317 | if ( $obj->isModerated() && !$obj->isLocked() ) { |
318 | $obj->contentLength = 0; |
319 | } else { |
320 | // reset content length (we may be restoring, in which case $obj's |
321 | // current length will be 0) |
322 | $obj->contentLength = $this->calculateContentLength(); |
323 | } |
324 | |
325 | return $obj; |
326 | } |
327 | |
328 | /** |
329 | * @param string $state |
330 | * @return bool |
331 | */ |
332 | public function isValidModerationState( $state ) { |
333 | return in_array( $state, self::$perms ); |
334 | } |
335 | |
336 | /** |
337 | * @return UUID |
338 | */ |
339 | public function getRevisionId() { |
340 | return $this->revId; |
341 | } |
342 | |
343 | /** |
344 | * @return bool |
345 | */ |
346 | public function hasHiddenContent() { |
347 | return $this->moderationState === self::MODERATED_HIDDEN; |
348 | } |
349 | |
350 | /** |
351 | * @return string |
352 | */ |
353 | public function getContentRaw() { |
354 | if ( $this->decompressedContent === null ) { |
355 | $this->decompressedContent = MediaWikiServices::getInstance() |
356 | ->getBlobStoreFactory() |
357 | ->newSqlBlobStore() |
358 | ->decompressData( $this->content, $this->flags ); |
359 | } |
360 | |
361 | return $this->decompressedContent; |
362 | } |
363 | |
364 | /** |
365 | * Checks whether the content is retrievable. |
366 | * |
367 | * False is an error state, used when the content is unretrievable, e.g. due to data loss (T95580) |
368 | * or a temporary database error. |
369 | * |
370 | * This is unrelated to whether the content is loaded on-demand. |
371 | * |
372 | * @return bool |
373 | */ |
374 | public function isContentCurrentlyRetrievable() { |
375 | return $this->content !== false; |
376 | } |
377 | |
378 | /** |
379 | * DO NOT USE THIS METHOD to output the content; use |
380 | * Templating::getContent, which will do additional (permissions-based) |
381 | * checks to make sure it outputs something the user can see. |
382 | * |
383 | * @param string $format Format to output content in |
384 | * (html|wikitext|topic-title-wikitext|topic-title-html|topic-title-plaintext) |
385 | * @return string |
386 | * @return-taint onlysafefor_htmlnoent |
387 | * @throws InvalidDataException |
388 | * @throws \Flow\Exception\WikitextException |
389 | */ |
390 | public function getContent( $format = 'html' ) { |
391 | if ( !$this->isContentCurrentlyRetrievable() ) { |
392 | wfDebugLog( 'Flow', __METHOD__ . ': Failed to load the content of revision with rev_id ' . |
393 | $this->revId->getAlphadecimal() ); |
394 | |
395 | $stubContent = wfMessage( 'flow-stub-post-content' )->parse(); |
396 | if ( !in_array( $format, [ 'html', 'fixed-html' ] ) ) { |
397 | $stubContent = Sanitizer::stripAllTags( $stubContent ); |
398 | } |
399 | |
400 | return $stubContent; |
401 | } |
402 | |
403 | if ( $this->xssCheck === false ) { |
404 | return ''; |
405 | } |
406 | $raw = $this->getContentRaw(); |
407 | $sourceFormat = $this->getContentFormat(); |
408 | if ( $this->xssCheck === null && $sourceFormat === 'html' ) { |
409 | // returns true if no handler aborted the hook |
410 | $this->xssCheck = ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) ) |
411 | ->onFlowCheckHtmlContentXss( $raw ); |
412 | if ( !$this->xssCheck ) { |
413 | wfDebugLog( 'Flow', __METHOD__ . ': XSS check prevented display of revision ' . |
414 | $this->revId->getAlphadecimal() ); |
415 | return ''; |
416 | } |
417 | } |
418 | |
419 | if ( !isset( $this->convertedContent[$format] ) ) { |
420 | if ( $sourceFormat === $format ) { |
421 | $this->convertedContent[$format] = $raw; |
422 | if ( in_array( $format, [ 'fixed-html', 'html' ] ) ) { |
423 | // For backwards compatibility wrap old content with body tag if necessary, |
424 | // and restore the <base> tag based on the base-url attribute on the body tag, |
425 | // if any. All of this is done by decodeHeadInfo(). |
426 | $this->convertedContent[$format] = Utils::decodeHeadInfo( $raw ); |
427 | } |
428 | } else { |
429 | $this->convertedContent[$format] = Utils::convert( |
430 | $sourceFormat, |
431 | $format, |
432 | $raw, |
433 | $this->getCollection()->getTitle() |
434 | ); |
435 | } |
436 | } |
437 | |
438 | return $this->convertedContent[$format]; |
439 | } |
440 | |
441 | /** |
442 | * Gets the content in a wikitext format. In this class, it will be 'wikitext', |
443 | * but this can be overriden in sub-classes (e.g. to 'topic-title-wikitext' for topic titles). |
444 | * |
445 | * DO NOT USE THIS METHOD to output the content; use Templating::getContent for security reasons. |
446 | * |
447 | * @return string Text in a wikitext-based format. |
448 | */ |
449 | public function getContentInWikitext() { |
450 | return $this->getContent( $this->getWikitextFormat() ); |
451 | } |
452 | |
453 | /** |
454 | * Gets a wikitext format that is suitable for this revision. |
455 | * In this class, it will be 'wikitext', but this can be overriden in sub-classes |
456 | * (e.g. to 'topic-title-wikitext' for topic titles). |
457 | * |
458 | * @return string Format name |
459 | */ |
460 | public function getWikitextFormat() { |
461 | return 'wikitext'; |
462 | } |
463 | |
464 | /** |
465 | * Gets the content in an HTML format. In this class, it will be 'html', |
466 | * but this can be overriden in sub-classes (e.g. to 'topic-title-html' for topic titles). |
467 | * |
468 | * DO NOT USE THIS METHOD to output the content; use Templating::getContent for security reasons. |
469 | * |
470 | * @return string Text in an HTML-based format. |
471 | */ |
472 | public function getContentInHtml() { |
473 | return $this->getContent( $this->getHtmlFormat() ); |
474 | } |
475 | |
476 | /** |
477 | * Gets an HTML format that is suitable for this revision. |
478 | * In this class, it will be 'html', but this can be overriden in sub-classes |
479 | * (e.g. to 'topic-title-html' for topic titles). |
480 | * |
481 | * @return string Format name |
482 | */ |
483 | public function getHtmlFormat() { |
484 | return 'html'; |
485 | } |
486 | |
487 | /** |
488 | * @return UserTuple |
489 | */ |
490 | public function getUserTuple() { |
491 | return $this->user; |
492 | } |
493 | |
494 | /** |
495 | * @return int |
496 | */ |
497 | public function getUserId() { |
498 | return $this->user->id; |
499 | } |
500 | |
501 | /** |
502 | * @return string|null |
503 | */ |
504 | public function getUserIp() { |
505 | return $this->user->ip; |
506 | } |
507 | |
508 | /** |
509 | * @return string |
510 | */ |
511 | public function getUserWiki() { |
512 | return $this->user->wiki; |
513 | } |
514 | |
515 | /** |
516 | * @return User |
517 | */ |
518 | public function getUser() { |
519 | return $this->user->createUser(); |
520 | } |
521 | |
522 | /** |
523 | * Should only be used for setting the initial content. To set subsequent content |
524 | * use self::setNextContent |
525 | * |
526 | * @param string $content |
527 | * @param string $format wikitext|html|topic-title-wikitext |
528 | * @param Title|null $title When null the related workflow will be lazy-loaded to locate the title |
529 | * @throws DataModelException |
530 | */ |
531 | protected function setContent( $content, $format, ?Title $title = null ) { |
532 | if ( $this->moderationState !== self::MODERATED_NONE ) { |
533 | throw new DataModelException( 'TODO: Cannot change content of restricted revision', |
534 | 'process-data' ); |
535 | } |
536 | |
537 | if ( $this->content !== null ) { |
538 | throw new DataModelException( 'Updating content must use setNextContent method', 'process-data' ); |
539 | } |
540 | |
541 | if ( !$title ) { |
542 | $title = $this->getCollection()->getTitle(); |
543 | } |
544 | |
545 | if ( $format !== 'wikitext' && $format !== 'html' && $format !== 'topic-title-wikitext' ) { |
546 | throw new DataModelException( 'Invalid format: Supported formats for new content are ' . |
547 | '\'wikitext\', \'html\', and \'topic-title-wikitext\'' ); |
548 | } |
549 | |
550 | // never trust incoming html - roundtrip to wikitext first |
551 | if ( $format === 'html' ) { |
552 | $content = Utils::convert( $format, 'wikitext', $content, $title ); |
553 | $format = 'wikitext'; |
554 | } |
555 | |
556 | if ( $format === 'wikitext' ) { |
557 | // Run pre-save transform |
558 | $services = MediaWikiServices::getInstance(); |
559 | $contentTransformer = $services->getContentTransformer(); |
560 | $content = $services->getContentHandlerFactory() |
561 | ->getContentHandler( CONTENT_MODEL_WIKITEXT ) |
562 | ->unserializeContent( $content ); |
563 | $content = $contentTransformer->preSaveTransform( |
564 | $content, |
565 | $title, |
566 | $this->getUser(), |
567 | $services->getWikiPageFactory() |
568 | ->newFromTitle( $title )->makeParserOptions( $this->getUser() ) |
569 | )->serialize( 'text/x-wiki' ); |
570 | } |
571 | |
572 | // Keep consistent with normal edit page, trim only trailing whitespaces |
573 | $content = rtrim( $content ); |
574 | $this->convertedContent = [ $format => $content ]; |
575 | |
576 | // convert content to desired storage format |
577 | $storageFormat = $this->getStorageFormat(); |
578 | if ( $storageFormat !== $format ) { |
579 | $this->convertedContent[$storageFormat] = Utils::convert( |
580 | $format, $storageFormat, $content, $title ); |
581 | } |
582 | |
583 | // @phan-suppress-next-line SecurityCheck-DoubleEscaped Seems a false positive |
584 | $this->setContentRaw( $this->convertedContent ); |
585 | } |
586 | |
587 | /** |
588 | * Helper function for setContent(). Don't call this directly. |
589 | * Also called by the FlowReserializeRevisionContent maintenance script using reflection. |
590 | * |
591 | * $convertedContent may contain 'html', 'wikitext' or both, but must at least contain the |
592 | * storage format (as returned by getStorageFormat()). |
593 | * |
594 | * @param array $convertedContent [ 'html' => string, 'wikitext' => string ] |
595 | */ |
596 | protected function setContentRaw( $convertedContent ) { |
597 | $storageFormat = $this->getStorageFormat(); |
598 | if ( !isset( $convertedContent[ $storageFormat ] ) ) { |
599 | throw new DataModelException( 'Content not given in storage format ' . $storageFormat ); |
600 | } |
601 | |
602 | $this->convertedContent = $convertedContent; |
603 | $this->content = $this->decompressedContent = $this->convertedContent[$storageFormat]; |
604 | $this->contentUrl = null; |
605 | |
606 | // should this only remove a subset of flags? |
607 | $compressed = MediaWikiServices::getInstance() |
608 | ->getBlobStoreFactory() |
609 | ->newSqlBlobStore() |
610 | ->compressData( $this->content ); |
611 | $this->flags = array_filter( explode( ',', $compressed ) ); |
612 | $this->flags[] = $storageFormat; |
613 | |
614 | $this->contentLength = $this->calculateContentLength(); |
615 | } |
616 | |
617 | /** |
618 | * Apply new content to a revision. |
619 | * |
620 | * @param User $user |
621 | * @param string $content |
622 | * @param string $format wikitext|html|topic-title-wikitext |
623 | * @param Title|null $title When null the related workflow will be lazy-loaded to locate the title |
624 | * @throws DataModelException |
625 | */ |
626 | protected function setNextContent( User $user, $content, $format, ?Title $title = null ) { |
627 | if ( $this->moderationState !== self::MODERATED_NONE ) { |
628 | throw new DataModelException( 'Cannot change content of restricted revision', 'process-data' ); |
629 | } |
630 | |
631 | // Do we need this if check, or just the one in newNextRevision against the prior revision? |
632 | if ( $content !== $this->getContent( $format ) ) { |
633 | $this->content = null; |
634 | $this->setContent( $content, $format, $title ); |
635 | $this->lastEditId = $this->getRevisionId(); |
636 | $this->lastEditUser = UserTuple::newFromUser( $user ); |
637 | } |
638 | } |
639 | |
640 | /** |
641 | * @return string The content format of this revision |
642 | */ |
643 | public function getContentFormat() { |
644 | return in_array( 'html', $this->flags ) ? 'html' : 'wikitext'; |
645 | } |
646 | |
647 | /** |
648 | * Determines the appropriate format to store content in. |
649 | * NOTE: The format of the current content is retrieved with getContentFormat |
650 | * |
651 | * @return string The name of the storage format. |
652 | */ |
653 | protected function getStorageFormat() { |
654 | global $wgFlowContentFormat; |
655 | |
656 | return $wgFlowContentFormat; |
657 | } |
658 | |
659 | /** |
660 | * @return UUID|null |
661 | */ |
662 | public function getPrevRevisionId() { |
663 | return $this->prevRevision; |
664 | } |
665 | |
666 | /** |
667 | * @return string |
668 | */ |
669 | public function getChangeType() { |
670 | return $this->changeType; |
671 | } |
672 | |
673 | /** |
674 | * @return string |
675 | */ |
676 | public function getModerationState() { |
677 | return $this->moderationState; |
678 | } |
679 | |
680 | /** |
681 | * @return string|null |
682 | */ |
683 | public function getModeratedReason() { |
684 | return $this->moderatedReason; |
685 | } |
686 | |
687 | /** |
688 | * @return bool |
689 | */ |
690 | public function isModerated() { |
691 | return $this->moderationState !== self::MODERATED_NONE; |
692 | } |
693 | |
694 | /** |
695 | * @return bool |
696 | */ |
697 | public function isHidden() { |
698 | return $this->moderationState === self::MODERATED_HIDDEN; |
699 | } |
700 | |
701 | /** |
702 | * @return bool |
703 | */ |
704 | public function isDeleted() { |
705 | return $this->moderationState === self::MODERATED_DELETED; |
706 | } |
707 | |
708 | /** |
709 | * @return bool |
710 | */ |
711 | public function isSuppressed() { |
712 | return $this->moderationState === self::MODERATED_SUPPRESSED; |
713 | } |
714 | |
715 | /** |
716 | * @return bool |
717 | */ |
718 | public function isLocked() { |
719 | return $this->moderationState === self::MODERATED_LOCKED; |
720 | } |
721 | |
722 | /** |
723 | * @return string|null Timestamp in TS_MW format |
724 | */ |
725 | public function getModerationTimestamp() { |
726 | return $this->moderationTimestamp; |
727 | } |
728 | |
729 | /** |
730 | * @param string|array $flags |
731 | * @return bool True when at least one flag in $flags is set |
732 | */ |
733 | public function isFlaggedAny( $flags ) { |
734 | foreach ( (array)$flags as $flag ) { |
735 | if ( in_array( $flag, $this->flags ) ) { |
736 | return true; |
737 | } |
738 | } |
739 | return false; |
740 | } |
741 | |
742 | /** |
743 | * @param string|array $flags |
744 | * @return bool |
745 | */ |
746 | public function isFlaggedAll( $flags ) { |
747 | foreach ( (array)$flags as $flag ) { |
748 | if ( !in_array( $flag, $this->flags ) ) { |
749 | return false; |
750 | } |
751 | } |
752 | return true; |
753 | } |
754 | |
755 | /** |
756 | * @return bool |
757 | */ |
758 | public function isFirstRevision() { |
759 | return $this->prevRevision === null; |
760 | } |
761 | |
762 | /** |
763 | * @return bool |
764 | */ |
765 | public function isOriginalContent() { |
766 | return $this->lastEditId === null; |
767 | } |
768 | |
769 | /** |
770 | * @return UUID |
771 | */ |
772 | public function getLastContentEditId() { |
773 | return $this->lastEditId; |
774 | } |
775 | |
776 | /** |
777 | * @return UserTuple |
778 | */ |
779 | public function getLastContentEditUserTuple() { |
780 | return $this->lastEditUser; |
781 | } |
782 | |
783 | /** |
784 | * @return int|null |
785 | */ |
786 | public function getLastContentEditUserId() { |
787 | return $this->lastEditUser ? $this->lastEditUser->id : null; |
788 | } |
789 | |
790 | /** |
791 | * @return string|null |
792 | */ |
793 | public function getLastContentEditUserIp() { |
794 | return $this->lastEditUser ? $this->lastEditUser->ip : null; |
795 | } |
796 | |
797 | /** |
798 | * @return string|null |
799 | */ |
800 | public function getLastContentEditUserWiki() { |
801 | return $this->lastEditUser ? $this->lastEditUser->wiki : null; |
802 | } |
803 | |
804 | /** |
805 | * @return UserTuple |
806 | */ |
807 | public function getModeratedByTuple() { |
808 | return $this->moderatedBy; |
809 | } |
810 | |
811 | /** |
812 | * @return int|null |
813 | */ |
814 | public function getModeratedByUserId() { |
815 | return $this->moderatedBy ? $this->moderatedBy->id : null; |
816 | } |
817 | |
818 | /** |
819 | * @return string|null |
820 | */ |
821 | public function getModeratedByUserIp() { |
822 | return $this->moderatedBy ? $this->moderatedBy->ip : null; |
823 | } |
824 | |
825 | /** |
826 | * @return string|null |
827 | */ |
828 | public function getModeratedByUserWiki() { |
829 | return $this->moderatedBy ? $this->moderatedBy->wiki : null; |
830 | } |
831 | |
832 | public static function getModerationChangeTypes() { |
833 | if ( self::$moderationChangeTypes === null ) { |
834 | self::$moderationChangeTypes = []; |
835 | foreach ( self::$perms as $perm ) { |
836 | if ( $perm != '' ) { |
837 | self::$moderationChangeTypes[] = "{$perm}-topic"; |
838 | self::$moderationChangeTypes[] = "{$perm}-post"; |
839 | } |
840 | } |
841 | |
842 | self::$moderationChangeTypes[] = 'restore-topic'; |
843 | self::$moderationChangeTypes[] = 'restore-post'; |
844 | } |
845 | |
846 | return self::$moderationChangeTypes; |
847 | } |
848 | |
849 | public function isModerationChange() { |
850 | return in_array( $this->getChangeType(), self::getModerationChangeTypes() ); |
851 | } |
852 | |
853 | /** |
854 | * @return int |
855 | */ |
856 | public function getContentLength() { |
857 | return $this->contentLength; |
858 | } |
859 | |
860 | // Only public for FlowUpdateRevisionContentLength. |
861 | |
862 | /** |
863 | * Determines the content length by measuring the actual content. |
864 | * |
865 | * @return int |
866 | */ |
867 | public function calculateContentLength() { |
868 | return mb_strlen( $this->getContentInWikitext() ); |
869 | } |
870 | |
871 | /** |
872 | * @return int |
873 | */ |
874 | public function getPreviousContentLength() { |
875 | return $this->previousContentLength; |
876 | } |
877 | |
878 | /** |
879 | * Finds the RecentChange object associated with this flow revision. |
880 | * |
881 | * @return null|RecentChange |
882 | */ |
883 | public function getRecentChange() { |
884 | $timestamp = $this->revId->getTimestamp(); |
885 | |
886 | if ( !RecentChange::isInRCLifespan( $timestamp ) ) { |
887 | // Too old to be in RC, don't even bother checking |
888 | return null; |
889 | } |
890 | $workflow = $this->getCollection()->getWorkflow(); |
891 | if ( $this->changeType === 'new-post' ) { |
892 | $title = $workflow->getOwnerTitle(); |
893 | } else { |
894 | $title = $workflow->getArticleTitle(); |
895 | } |
896 | $namespace = $title->getNamespace(); |
897 | |
898 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
899 | $rcQuery = RecentChange::getQueryInfo(); |
900 | $rows = $dbr->newSelectQueryBuilder() |
901 | ->tables( $rcQuery['tables'] ) |
902 | ->fields( $rcQuery['fields'] ) |
903 | ->where( [ |
904 | 'rc_title' => $title->getDBkey(), |
905 | 'rc_timestamp' => $timestamp, |
906 | 'rc_namespace' => $namespace, |
907 | ] ) |
908 | ->useIndex( [ 'recentchanges' => 'rc_timestamp' ] ) |
909 | ->joinConds( $rcQuery['joins'] ) |
910 | ->caller( __METHOD__ ) |
911 | ->fetchResultSet(); |
912 | |
913 | if ( $rows->numRows() === 1 ) { |
914 | return RecentChange::newFromRow( $rows->fetchObject() ); |
915 | } |
916 | |
917 | // it is possible that more than 1 changes on the same page have the same timestamp |
918 | // the revision id is hidden in rc_params['flow-workflow-change']['revision'] |
919 | $revId = $this->revId->getAlphadecimal(); |
920 | foreach ( $rows as $row ) { |
921 | $rc = RecentChange::newFromRow( $row ); |
922 | $params = $rc->parseParams(); |
923 | if ( isset( $params['flow-workflow-change'] ) && |
924 | $params['flow-workflow-change']['revision'] === $revId |
925 | ) { |
926 | return $rc; |
927 | } |
928 | } |
929 | |
930 | return null; |
931 | } |
932 | |
933 | /** |
934 | * @return UserTuple |
935 | */ |
936 | public function getCreatorTuple() { |
937 | if ( !$this->creator ) { |
938 | if ( $this->isFirstRevision() ) { |
939 | $this->creator = $this->user; |
940 | } else { |
941 | $this->creator = $this->getCollection()->getFirstRevision()->getUserTuple(); |
942 | } |
943 | } |
944 | |
945 | return $this->creator; |
946 | } |
947 | |
948 | /** |
949 | * Get the user ID of the user who created this summary. |
950 | * |
951 | * @return int The user ID |
952 | */ |
953 | public function getCreatorId() { |
954 | return $this->getCreatorTuple()->id; |
955 | } |
956 | |
957 | /** |
958 | * @return string |
959 | */ |
960 | public function getCreatorWiki() { |
961 | return $this->getCreatorTuple()->wiki; |
962 | } |
963 | |
964 | /** |
965 | * Get the user ip of the user who created this summary if it |
966 | * was created by an anonymous user |
967 | * |
968 | * @return string|null String if an creator is anon, or null if not. |
969 | */ |
970 | public function getCreatorIp() { |
971 | return $this->getCreatorTuple()->ip; |
972 | } |
973 | |
974 | /** |
975 | * @param AbstractRevision $revision |
976 | * @return bool |
977 | * @throws InvalidDataException |
978 | */ |
979 | protected function hasSameContentAs( AbstractRevision $revision ) { |
980 | return $this->getContentInWikitext() === $revision->getContentInWikitext(); |
981 | } |
982 | |
983 | /** |
984 | * @return string |
985 | */ |
986 | abstract public function getRevisionType(); |
987 | |
988 | /** |
989 | * @return UUID |
990 | */ |
991 | abstract public function getCollectionId(); |
992 | |
993 | /** |
994 | * @return AbstractCollection |
995 | */ |
996 | abstract public function getCollection(); |
997 | } |