Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
53.28% |
146 / 274 |
|
49.15% |
29 / 59 |
CRAP | |
0.00% |
0 / 1 |
| AbstractRevision | |
53.28% |
146 / 274 |
|
49.15% |
29 / 59 |
1744.52 | |
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 | |
53.33% |
16 / 30 |
|
0.00% |
0 / 1 |
20.16 | |||
| 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 | } |