Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.65% covered (warning)
53.65%
147 / 274
49.15% covered (danger)
49.15%
29 / 59
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractRevision
53.65% covered (warning)
53.65%
147 / 274
49.15% covered (danger)
49.15%
29 / 59
1706.89
0.00% covered (danger)
0.00%
0 / 1
 fromStorageRow
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
10.03
 toStorageRow
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
9
 newNullRevision
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
2.02
 newNextRevision
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 moderate
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 isValidModerationState
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRevisionId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasHiddenContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContentRaw
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 isContentCurrentlyRetrievable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContent
56.67% covered (warning)
56.67%
17 / 30
0.00% covered (danger)
0.00%
0 / 1
18.14
 getContentInWikitext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWikitextFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContentInHtml
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHtmlFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserTuple
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserIp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserWiki
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setContent
33.33% covered (danger)
33.33%
11 / 33
0.00% covered (danger)
0.00%
0 / 1
39.63
 setContentRaw
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
2.00
 setNextContent
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 getContentFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getStorageFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrevRevisionId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getChangeType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModerationState
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getModeratedReason
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isModerated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isHidden
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDeleted
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isSuppressed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isLocked
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModerationTimestamp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFlaggedAny
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 isFlaggedAll
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 isFirstRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isOriginalContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastContentEditId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastContentEditUserTuple
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLastContentEditUserId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getLastContentEditUserIp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getLastContentEditUserWiki
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getModeratedByTuple
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModeratedByUserId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getModeratedByUserIp
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getModeratedByUserWiki
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getModerationChangeTypes
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
11.53
 isModerationChange
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContentLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 calculateContentLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPreviousContentLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRecentChange
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
56
 getCreatorTuple
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getCreatorId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCreatorWiki
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCreatorIp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasSameContentAs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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
3namespace Flow\Model;
4
5use Flow\Collection\AbstractCollection;
6use Flow\Conversion\Utils;
7use Flow\Exception\DataModelException;
8use Flow\Exception\InvalidDataException;
9use Flow\Exception\PermissionException;
10use Flow\Hooks\HookRunner;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\Parser\Sanitizer;
13use MediaWiki\RecentChanges\RecentChange;
14use MediaWiki\Title\Title;
15use MediaWiki\User\User;
16
17abstract 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}