Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
59.08% |
345 / 584 |
|
27.03% |
10 / 37 |
CRAP | |
0.00% |
0 / 1 |
RecentChange | |
59.08% |
345 / 584 |
|
27.03% |
10 / 37 |
1801.72 | |
0.00% |
0 / 1 |
newFromRow | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
parseToRCType | |
37.50% |
3 / 8 |
|
0.00% |
0 / 1 |
7.91 | |||
parseFromRCType | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
getChangeTypes | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
newFromId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
newFromConds | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
getQueryInfo | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
2 | |||
__construct | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
1.01 | |||
setAttribs | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setExtra | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getTitle | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getPage | |
22.22% |
2 / 9 |
|
0.00% |
0 / 1 |
7.23 | |||
getPerformerIdentity | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
save | |
75.29% |
64 / 85 |
|
0.00% |
0 / 1 |
24.44 | |||
notifyRCFeeds | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
13 | |||
doMarkPatrolled | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
markPatrolled | |
88.24% |
30 / 34 |
|
0.00% |
0 / 1 |
15.37 | |||
reallyMarkPatrolled | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
12 | |||
notifyEdit | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
12 | |||
notifyNew | |
100.00% |
42 / 42 |
|
100.00% |
1 / 1 |
3 | |||
notifyLog | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
newLogEntry | |
79.41% |
54 / 68 |
|
0.00% |
0 / 1 |
18.23 | |||
newForCategorization | |
95.92% |
47 / 49 |
|
0.00% |
0 / 1 |
4 | |||
getParam | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
loadFromRow | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
3 | |||
getAttribute | |
60.00% |
9 / 15 |
|
0.00% |
0 / 1 |
12.10 | |||
getAttributes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
diffLinkTrail | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
getCharacterDifference | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
30 | |||
checkIPAddress | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
4.84 | |||
isInRCLifespan | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
isEnotifEnabled | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
getNotifyUrl | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
6 | |||
parseParams | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
addTags | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
setEditResult | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUserIdentityFromAnyId | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
156 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | use MediaWiki\ChangeTags\Taggable; |
22 | use MediaWiki\Config\Config; |
23 | use MediaWiki\Debug\DeprecationHelper; |
24 | use MediaWiki\HookContainer\HookRunner; |
25 | use MediaWiki\Json\FormatJson; |
26 | use MediaWiki\MainConfigNames; |
27 | use MediaWiki\MediaWikiServices; |
28 | use MediaWiki\Page\PageIdentity; |
29 | use MediaWiki\Page\PageReference; |
30 | use MediaWiki\Page\PageReferenceValue; |
31 | use MediaWiki\Permissions\Authority; |
32 | use MediaWiki\Permissions\PermissionStatus; |
33 | use MediaWiki\RCFeed\RCFeed; |
34 | use MediaWiki\Storage\EditResult; |
35 | use MediaWiki\Title\Title; |
36 | use MediaWiki\User\UserIdentity; |
37 | use MediaWiki\User\UserIdentityValue; |
38 | use MediaWiki\Utils\MWTimestamp; |
39 | use Wikimedia\Assert\Assert; |
40 | use Wikimedia\AtEase\AtEase; |
41 | use Wikimedia\IPUtils; |
42 | |
43 | /** |
44 | * @defgroup RecentChanges Recent changes |
45 | * Discovery and review of recent edits and log events on the wiki. |
46 | * |
47 | * The Recent changes feature stores a temporary copy of the long-term |
48 | * `revision` and `logging` table rows which represent page edits and |
49 | * log actions respectively. |
50 | * |
51 | * Recent changes augments revision and logging rows with additional metadata |
52 | * that empower reviewers to efficiently find edits related to their |
53 | * interest, or edits that warrant a closer look. This includes page |
54 | * namespace, "minor" edit status, and user type. As well as metadata we |
55 | * don't store elsewhere, such the bot flag (rc_bot), edit type (page creation, |
56 | * edit, or something else), and patrolling state (rc_patrolled). |
57 | * |
58 | * The patrolled status facilitates edit review via the "mark as patrolled" |
59 | * button, in combination with filtering by patrol status via SpecialRecentChanges, |
60 | * SpecialWatchlist, and ApiQueryRecentChanges. |
61 | */ |
62 | |
63 | /** |
64 | * Utility class for creating and reading rows in the recentchanges table. |
65 | * |
66 | * mAttribs: |
67 | * rc_id id of the row in the recentchanges table |
68 | * rc_timestamp time the entry was made |
69 | * rc_namespace namespace # |
70 | * rc_title non-prefixed db key |
71 | * rc_type is new entry, used to determine whether updating is necessary |
72 | * rc_source string representation of change source |
73 | * rc_minor is minor |
74 | * rc_cur_id page_id of associated page entry |
75 | * rc_user user id who made the entry |
76 | * rc_user_text user name who made the entry |
77 | * rc_comment edit summary |
78 | * rc_this_oldid rev_id associated with this entry (or zero) |
79 | * rc_last_oldid rev_id associated with the entry before this one (or zero) |
80 | * rc_bot is bot, hidden |
81 | * rc_ip IP address of the user in dotted quad notation |
82 | * rc_new obsolete, use rc_source=='mw.new' |
83 | * rc_patrolled boolean whether or not someone has marked this edit as patrolled |
84 | * rc_old_len integer byte length of the text before the edit |
85 | * rc_new_len the same after the edit |
86 | * rc_deleted partial deletion |
87 | * rc_logid the log_id value for this log entry (or zero) |
88 | * rc_log_type the log type (or null) |
89 | * rc_log_action the log action (or null) |
90 | * rc_params log params |
91 | * |
92 | * mExtra: |
93 | * prefixedDBkey prefixed db key, used by external app via msg queue |
94 | * lastTimestamp timestamp of previous entry, used in WHERE clause during update |
95 | * oldSize text size before the change |
96 | * newSize text size after the change |
97 | * pageStatus status of the page: created, deleted, moved, restored, changed |
98 | * |
99 | * temporary: not stored in the database |
100 | * notificationtimestamp |
101 | * numberofWatchingusers |
102 | * watchlistExpiry for temporary watchlist items |
103 | * |
104 | * @todo Deprecate access to mAttribs (direct or via getAttributes). Right now |
105 | * we're having to include both rc_comment and rc_comment_text/rc_comment_data |
106 | * so random crap works right. |
107 | * |
108 | * @ingroup RecentChanges |
109 | */ |
110 | class RecentChange implements Taggable { |
111 | use DeprecationHelper; |
112 | |
113 | // Constants for the rc_source field. Extensions may also have |
114 | // their own source constants. |
115 | public const SRC_EDIT = 'mw.edit'; |
116 | public const SRC_NEW = 'mw.new'; |
117 | public const SRC_LOG = 'mw.log'; |
118 | public const SRC_EXTERNAL = 'mw.external'; // obsolete |
119 | public const SRC_CATEGORIZE = 'mw.categorize'; |
120 | |
121 | public const PRC_UNPATROLLED = 0; |
122 | public const PRC_PATROLLED = 1; |
123 | public const PRC_AUTOPATROLLED = 2; |
124 | |
125 | /** |
126 | * @var bool For save() - save to the database only, without any events. |
127 | */ |
128 | public const SEND_NONE = true; |
129 | |
130 | /** |
131 | * @var bool For save() - do emit the change to RCFeeds (usually public). |
132 | */ |
133 | public const SEND_FEED = false; |
134 | |
135 | /** @var array */ |
136 | public $mAttribs = []; |
137 | /** @var array */ |
138 | public $mExtra = []; |
139 | |
140 | /** |
141 | * @var PageReference|null |
142 | */ |
143 | private $mPage = null; |
144 | |
145 | /** |
146 | * @var UserIdentity|null |
147 | */ |
148 | private $mPerformer = null; |
149 | |
150 | /** @var int */ |
151 | public $numberofWatchingusers = 0; |
152 | /** @var bool */ |
153 | public $notificationtimestamp; |
154 | |
155 | /** |
156 | * @var string|null The expiry time, if this is a temporary watchlist item. |
157 | */ |
158 | public $watchlistExpiry; |
159 | |
160 | /** |
161 | * @var int Line number of recent change. Default -1. |
162 | */ |
163 | public $counter = -1; |
164 | |
165 | /** |
166 | * @var array List of tags to apply |
167 | */ |
168 | private $tags = []; |
169 | |
170 | /** |
171 | * @var EditResult|null EditResult associated with the edit |
172 | */ |
173 | private $editResult = null; |
174 | |
175 | private const CHANGE_TYPES = [ |
176 | 'edit' => RC_EDIT, |
177 | 'new' => RC_NEW, |
178 | 'log' => RC_LOG, |
179 | 'external' => RC_EXTERNAL, |
180 | 'categorize' => RC_CATEGORIZE, |
181 | ]; |
182 | |
183 | # Factory methods |
184 | |
185 | /** |
186 | * @param mixed $row |
187 | * @return RecentChange |
188 | */ |
189 | public static function newFromRow( $row ) { |
190 | $rc = new RecentChange; |
191 | $rc->loadFromRow( $row ); |
192 | |
193 | return $rc; |
194 | } |
195 | |
196 | /** |
197 | * Parsing text to RC_* constants |
198 | * @since 1.24 |
199 | * @param string|array $type Callers must make sure that the given types are valid RC types. |
200 | * @return int|array RC_TYPE |
201 | */ |
202 | public static function parseToRCType( $type ) { |
203 | if ( is_array( $type ) ) { |
204 | $retval = []; |
205 | foreach ( $type as $t ) { |
206 | $retval[] = self::parseToRCType( $t ); |
207 | } |
208 | |
209 | return $retval; |
210 | } |
211 | |
212 | if ( !array_key_exists( $type, self::CHANGE_TYPES ) ) { |
213 | throw new InvalidArgumentException( "Unknown type '$type'" ); |
214 | } |
215 | return self::CHANGE_TYPES[$type]; |
216 | } |
217 | |
218 | /** |
219 | * Parsing RC_* constants to human-readable test |
220 | * @since 1.24 |
221 | * @param int $rcType |
222 | * @return string |
223 | */ |
224 | public static function parseFromRCType( $rcType ) { |
225 | return array_search( $rcType, self::CHANGE_TYPES, true ) ?: "$rcType"; |
226 | } |
227 | |
228 | /** |
229 | * Get an array of all change types |
230 | * |
231 | * @since 1.26 |
232 | * |
233 | * @return array |
234 | */ |
235 | public static function getChangeTypes() { |
236 | return array_keys( self::CHANGE_TYPES ); |
237 | } |
238 | |
239 | /** |
240 | * Obtain the recent change with a given rc_id value |
241 | * |
242 | * @param int $rcid The rc_id value to retrieve |
243 | * @return RecentChange|null |
244 | */ |
245 | public static function newFromId( $rcid ) { |
246 | return self::newFromConds( [ 'rc_id' => $rcid ], __METHOD__ ); |
247 | } |
248 | |
249 | /** |
250 | * Find the first recent change matching some specific conditions |
251 | * |
252 | * @param array $conds Array of conditions |
253 | * @param mixed $fname Override the method name in profiling/logs @phan-mandatory-param |
254 | * @param int $dbType DB_* constant |
255 | * |
256 | * @return RecentChange|null |
257 | */ |
258 | public static function newFromConds( |
259 | $conds, |
260 | $fname = __METHOD__, |
261 | $dbType = DB_REPLICA |
262 | ) { |
263 | $icp = MediaWikiServices::getInstance()->getConnectionProvider(); |
264 | |
265 | $db = ( $dbType === DB_REPLICA ) ? $icp->getReplicaDatabase() : $icp->getPrimaryDatabase(); |
266 | |
267 | $rcQuery = self::getQueryInfo(); |
268 | $row = $db->newSelectQueryBuilder() |
269 | ->queryInfo( $rcQuery ) |
270 | ->where( $conds ) |
271 | ->caller( $fname ) |
272 | ->fetchRow(); |
273 | if ( $row !== false ) { |
274 | return self::newFromRow( $row ); |
275 | } else { |
276 | return null; |
277 | } |
278 | } |
279 | |
280 | /** |
281 | * Return the tables, fields, and join conditions to be selected to create |
282 | * a new recentchanges object. |
283 | * |
284 | * Since 1.34, rc_user and rc_user_text have not been present in the |
285 | * database, but they continue to be available in query results as |
286 | * aliases. |
287 | * |
288 | * @since 1.31 |
289 | * @return array[] With three keys: |
290 | * - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables` |
291 | * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` or `SelectQueryBuilder::fields` |
292 | * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds` |
293 | * @phan-return array{tables:string[],fields:string[],joins:array} |
294 | */ |
295 | public static function getQueryInfo() { |
296 | $commentQuery = MediaWikiServices::getInstance()->getCommentStore()->getJoin( 'rc_comment' ); |
297 | // Optimizer sometimes refuses to pick up the correct join order (T311360) |
298 | $commentQuery['joins']['comment_rc_comment'][0] = 'STRAIGHT_JOIN'; |
299 | return [ |
300 | 'tables' => [ |
301 | 'recentchanges', |
302 | 'recentchanges_actor' => 'actor' |
303 | ] + $commentQuery['tables'], |
304 | 'fields' => [ |
305 | 'rc_id', |
306 | 'rc_timestamp', |
307 | 'rc_namespace', |
308 | 'rc_title', |
309 | 'rc_minor', |
310 | 'rc_bot', |
311 | 'rc_new', |
312 | 'rc_cur_id', |
313 | 'rc_this_oldid', |
314 | 'rc_last_oldid', |
315 | 'rc_type', |
316 | 'rc_source', |
317 | 'rc_patrolled', |
318 | 'rc_ip', |
319 | 'rc_old_len', |
320 | 'rc_new_len', |
321 | 'rc_deleted', |
322 | 'rc_logid', |
323 | 'rc_log_type', |
324 | 'rc_log_action', |
325 | 'rc_params', |
326 | 'rc_actor', |
327 | 'rc_user' => 'recentchanges_actor.actor_user', |
328 | 'rc_user_text' => 'recentchanges_actor.actor_name', |
329 | ] + $commentQuery['fields'], |
330 | 'joins' => [ |
331 | 'recentchanges_actor' => [ 'STRAIGHT_JOIN', 'actor_id=rc_actor' ] |
332 | ] + $commentQuery['joins'], |
333 | ]; |
334 | } |
335 | |
336 | public function __construct() { |
337 | $this->deprecatePublicPropertyFallback( |
338 | 'mTitle', |
339 | '1.37', |
340 | function () { |
341 | return Title::castFromPageReference( $this->mPage ); |
342 | }, |
343 | function ( ?Title $title ) { |
344 | $this->mPage = $title; |
345 | } |
346 | ); |
347 | } |
348 | |
349 | # Accessors |
350 | |
351 | /** |
352 | * @param array $attribs |
353 | */ |
354 | public function setAttribs( $attribs ) { |
355 | $this->mAttribs = $attribs; |
356 | } |
357 | |
358 | /** |
359 | * @param array $extra |
360 | */ |
361 | public function setExtra( $extra ) { |
362 | $this->mExtra = $extra; |
363 | } |
364 | |
365 | /** |
366 | * @deprecated since 1.37, use getPage() instead. |
367 | * @return Title |
368 | */ |
369 | public function getTitle() { |
370 | $this->mPage = Title::castFromPageReference( $this->getPage() ); |
371 | return $this->mPage ?: Title::makeTitle( NS_SPECIAL, 'BadTitle' ); |
372 | } |
373 | |
374 | /** |
375 | * @since 1.37 |
376 | * @return ?PageReference |
377 | */ |
378 | public function getPage(): ?PageReference { |
379 | if ( !$this->mPage ) { |
380 | // NOTE: As per the 1.36 release, we always provide rc_title, |
381 | // even in cases where it doesn't really make sense. |
382 | // In the future, rc_title may be nullable, or we may use |
383 | // empty strings in entries that do not refer to a page. |
384 | if ( ( $this->mAttribs['rc_title'] ?? '' ) === '' ) { |
385 | return null; |
386 | } |
387 | |
388 | // XXX: We could use rc_cur_id to create a PageIdentityValue, |
389 | // at least if it's not a special page. |
390 | // However, newForCategorization() puts the ID of the categorized page into |
391 | // rc_cur_id, but the title of the category page into rc_title. |
392 | $this->mPage = new PageReferenceValue( |
393 | (int)$this->mAttribs['rc_namespace'], |
394 | $this->mAttribs['rc_title'], |
395 | PageReference::LOCAL |
396 | ); |
397 | } |
398 | |
399 | return $this->mPage; |
400 | } |
401 | |
402 | /** |
403 | * Get the UserIdentity of the client that performed this change. |
404 | * |
405 | * @since 1.36 |
406 | * |
407 | * @return UserIdentity |
408 | */ |
409 | public function getPerformerIdentity(): UserIdentity { |
410 | if ( !$this->mPerformer ) { |
411 | $this->mPerformer = $this->getUserIdentityFromAnyId( |
412 | $this->mAttribs['rc_user'] ?? null, |
413 | $this->mAttribs['rc_user_text'] ?? null, |
414 | $this->mAttribs['rc_actor'] ?? null |
415 | ); |
416 | } |
417 | |
418 | return $this->mPerformer; |
419 | } |
420 | |
421 | /** |
422 | * Writes the data in this object to the database |
423 | * |
424 | * For compatibility reasons, the SEND_ constants internally reference a value |
425 | * that may seem negated from their purpose (none=true, feed=false). This is |
426 | * because the parameter used to be called "$noudp", defaulting to false. |
427 | * |
428 | * @param bool $send self::SEND_FEED or self::SEND_NONE |
429 | */ |
430 | public function save( $send = self::SEND_FEED ) { |
431 | $services = MediaWikiServices::getInstance(); |
432 | $mainConfig = $services->getMainConfig(); |
433 | $putIPinRC = $mainConfig->get( MainConfigNames::PutIPinRC ); |
434 | $dbw = $services->getConnectionProvider()->getPrimaryDatabase(); |
435 | if ( !is_array( $this->mExtra ) ) { |
436 | $this->mExtra = []; |
437 | } |
438 | |
439 | if ( !$putIPinRC ) { |
440 | $this->mAttribs['rc_ip'] = ''; |
441 | } |
442 | |
443 | # Strict mode fixups (not-NULL fields) |
444 | foreach ( [ 'minor', 'bot', 'new', 'patrolled', 'deleted' ] as $field ) { |
445 | $this->mAttribs["rc_$field"] = (int)$this->mAttribs["rc_$field"]; |
446 | } |
447 | # ...more fixups (NULL fields) |
448 | foreach ( [ 'old_len', 'new_len' ] as $field ) { |
449 | $this->mAttribs["rc_$field"] = isset( $this->mAttribs["rc_$field"] ) |
450 | ? (int)$this->mAttribs["rc_$field"] |
451 | : null; |
452 | } |
453 | |
454 | $row = $this->mAttribs; |
455 | |
456 | # Trim spaces on user supplied text |
457 | $row['rc_comment'] = trim( $row['rc_comment'] ?? '' ); |
458 | |
459 | # Fixup database timestamps |
460 | $row['rc_timestamp'] = $dbw->timestamp( $row['rc_timestamp'] ); |
461 | |
462 | # # If we are using foreign keys, an entry of 0 for the page_id will fail, so use NULL |
463 | if ( $row['rc_cur_id'] == 0 ) { |
464 | unset( $row['rc_cur_id'] ); |
465 | } |
466 | |
467 | # Convert mAttribs['rc_comment'] for CommentStore |
468 | $comment = $row['rc_comment']; |
469 | unset( $row['rc_comment'], $row['rc_comment_text'], $row['rc_comment_data'] ); |
470 | $row += $services->getCommentStore()->insert( $dbw, 'rc_comment', $comment ); |
471 | |
472 | # Normalize UserIdentity to actor ID |
473 | $user = $this->getPerformerIdentity(); |
474 | if ( array_key_exists( 'forImport', $this->mExtra ) && $this->mExtra['forImport'] ) { |
475 | $actorStore = $services->getActorStoreFactory()->getActorStoreForImport(); |
476 | } else { |
477 | $actorStore = $services->getActorStoreFactory()->getActorStore(); |
478 | } |
479 | $row['rc_actor'] = $actorStore->acquireActorId( $user, $dbw ); |
480 | unset( $row['rc_user'], $row['rc_user_text'] ); |
481 | |
482 | # Don't reuse an existing rc_id for the new row, if one happens to be |
483 | # set for some reason. |
484 | unset( $row['rc_id'] ); |
485 | |
486 | # Insert new row |
487 | $dbw->newInsertQueryBuilder() |
488 | ->insertInto( 'recentchanges' ) |
489 | ->row( $row ) |
490 | ->caller( __METHOD__ )->execute(); |
491 | |
492 | # Set the ID |
493 | $this->mAttribs['rc_id'] = $dbw->insertId(); |
494 | |
495 | # Notify extensions |
496 | $hookRunner = new HookRunner( $services->getHookContainer() ); |
497 | $hookRunner->onRecentChange_save( $this ); |
498 | |
499 | // Apply revert tags (if needed) |
500 | if ( $this->editResult !== null && count( $this->editResult->getRevertTags() ) ) { |
501 | MediaWikiServices::getInstance()->getChangeTagsStore()->addTags( |
502 | $this->editResult->getRevertTags(), |
503 | $this->mAttribs['rc_id'], |
504 | $this->mAttribs['rc_this_oldid'], |
505 | $this->mAttribs['rc_logid'], |
506 | FormatJson::encode( $this->editResult ), |
507 | $this |
508 | ); |
509 | } |
510 | |
511 | if ( count( $this->tags ) ) { |
512 | // $this->tags may contain revert tags we already applied above, they will |
513 | // just be ignored. |
514 | MediaWikiServices::getInstance()->getChangeTagsStore()->addTags( |
515 | $this->tags, |
516 | $this->mAttribs['rc_id'], |
517 | $this->mAttribs['rc_this_oldid'], |
518 | $this->mAttribs['rc_logid'], |
519 | null, |
520 | $this |
521 | ); |
522 | } |
523 | |
524 | if ( $send === self::SEND_FEED ) { |
525 | // Emit the change to external applications via RCFeeds. |
526 | $this->notifyRCFeeds(); |
527 | } |
528 | |
529 | # E-mail notifications |
530 | if ( self::isEnotifEnabled( $mainConfig ) ) { |
531 | $userFactory = $services->getUserFactory(); |
532 | $editor = $userFactory->newFromUserIdentity( $this->getPerformerIdentity() ); |
533 | $page = $this->getPage(); |
534 | $title = Title::castFromPageReference( $page ); |
535 | |
536 | // Never send an RC notification email about categorization changes |
537 | if ( |
538 | $title && |
539 | $hookRunner->onAbortEmailNotification( $editor, $title, $this ) && |
540 | $this->mAttribs['rc_type'] != RC_CATEGORIZE |
541 | ) { |
542 | // @FIXME: This would be better as an extension hook |
543 | // Send emails or email jobs once this row is safely committed |
544 | $dbw->onTransactionCommitOrIdle( |
545 | function () use ( $editor, $title ) { |
546 | $enotif = new EmailNotification(); |
547 | $enotif->notifyOnPageChange( |
548 | $editor, |
549 | $title, |
550 | $this->mAttribs['rc_timestamp'], |
551 | $this->mAttribs['rc_comment'], |
552 | $this->mAttribs['rc_minor'], |
553 | $this->mAttribs['rc_last_oldid'], |
554 | $this->mExtra['pageStatus'] |
555 | ); |
556 | }, |
557 | __METHOD__ |
558 | ); |
559 | } |
560 | } |
561 | |
562 | $jobs = []; |
563 | // Flush old entries from the `recentchanges` table |
564 | if ( mt_rand( 0, 9 ) == 0 ) { |
565 | $jobs[] = RecentChangesUpdateJob::newPurgeJob(); |
566 | } |
567 | // Update the cached list of active users |
568 | if ( $this->mAttribs['rc_user'] > 0 ) { |
569 | $jobs[] = RecentChangesUpdateJob::newCacheUpdateJob(); |
570 | } |
571 | $services->getJobQueueGroup()->lazyPush( $jobs ); |
572 | } |
573 | |
574 | /** |
575 | * Notify all the feeds about the change. |
576 | * @param array|null $feeds Optional feeds to send to, defaults to $wgRCFeeds |
577 | */ |
578 | public function notifyRCFeeds( ?array $feeds = null ) { |
579 | $feeds ??= |
580 | MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::RCFeeds ); |
581 | |
582 | $performer = $this->getPerformerIdentity(); |
583 | |
584 | foreach ( $feeds as $params ) { |
585 | $params += [ |
586 | 'omit_bots' => false, |
587 | 'omit_anon' => false, |
588 | 'omit_user' => false, |
589 | 'omit_minor' => false, |
590 | 'omit_patrolled' => false, |
591 | ]; |
592 | |
593 | if ( |
594 | ( $params['omit_bots'] && $this->mAttribs['rc_bot'] ) || |
595 | ( $params['omit_anon'] && !$performer->isRegistered() ) || |
596 | ( $params['omit_user'] && $performer->isRegistered() ) || |
597 | ( $params['omit_minor'] && $this->mAttribs['rc_minor'] ) || |
598 | ( $params['omit_patrolled'] && $this->mAttribs['rc_patrolled'] ) || |
599 | $this->mAttribs['rc_type'] == RC_EXTERNAL |
600 | ) { |
601 | continue; |
602 | } |
603 | |
604 | $actionComment = $this->mExtra['actionCommentIRC'] ?? null; |
605 | |
606 | $feed = RCFeed::factory( $params ); |
607 | $feed->notify( $this, $actionComment ); |
608 | } |
609 | } |
610 | |
611 | /** |
612 | * Mark this RecentChange as patrolled |
613 | * |
614 | * NOTE: Can also return 'rcpatroldisabled', 'hookaborted' and |
615 | * 'markedaspatrollederror-noautopatrol' as errors |
616 | * |
617 | * @deprecated since 1.43 Use markPatrolled() instead |
618 | * |
619 | * @param Authority $performer User performing the action |
620 | * @param bool|null $auto Unused. Passing true logs a warning. |
621 | * @param string|string[]|null $tags Change tags to add to the patrol log entry |
622 | * ($user should be able to add the specified tags before this is called) |
623 | * @return array[] Array of permissions errors, see PermissionManager::getPermissionErrors() |
624 | */ |
625 | public function doMarkPatrolled( Authority $performer, $auto = null, $tags = null ) { |
626 | wfDeprecated( __METHOD__, '1.43' ); |
627 | if ( $auto ) { |
628 | wfWarn( __METHOD__ . ' with $auto = true' ); |
629 | return []; |
630 | } |
631 | return $this->markPatrolled( $performer, $tags )->toLegacyErrorArray(); |
632 | } |
633 | |
634 | /** |
635 | * Mark this RecentChange as patrolled |
636 | * |
637 | * NOTE: Can also return 'rcpatroldisabled', 'hookaborted' and |
638 | * 'markedaspatrollederror-noautopatrol' as errors |
639 | * |
640 | * @param Authority $performer User performing the action |
641 | * @param string|string[]|null $tags Change tags to add to the patrol log entry |
642 | * ($user should be able to add the specified tags before this is called) |
643 | * @return PermissionStatus |
644 | */ |
645 | public function markPatrolled( Authority $performer, $tags = null ): PermissionStatus { |
646 | $services = MediaWikiServices::getInstance(); |
647 | $mainConfig = $services->getMainConfig(); |
648 | $useRCPatrol = $mainConfig->get( MainConfigNames::UseRCPatrol ); |
649 | $useNPPatrol = $mainConfig->get( MainConfigNames::UseNPPatrol ); |
650 | $useFilePatrol = $mainConfig->get( MainConfigNames::UseFilePatrol ); |
651 | // Fix up $tags so that the MarkPatrolled hook below always gets an array |
652 | if ( $tags === null ) { |
653 | $tags = []; |
654 | } elseif ( is_string( $tags ) ) { |
655 | $tags = [ $tags ]; |
656 | } |
657 | |
658 | $status = PermissionStatus::newEmpty(); |
659 | // If recentchanges patrol is disabled, only new pages or new file versions |
660 | // can be patrolled, provided the appropriate config variable is set |
661 | if ( !$useRCPatrol && ( !$useNPPatrol || $this->getAttribute( 'rc_type' ) != RC_NEW ) && |
662 | ( !$useFilePatrol || !( $this->getAttribute( 'rc_type' ) == RC_LOG && |
663 | $this->getAttribute( 'rc_log_type' ) == 'upload' ) ) ) { |
664 | $status->fatal( 'rcpatroldisabled' ); |
665 | } |
666 | $performer->authorizeWrite( 'patrol', $this->getTitle(), $status ); |
667 | $user = $services->getUserFactory()->newFromAuthority( $performer ); |
668 | $hookRunner = new HookRunner( $services->getHookContainer() ); |
669 | if ( !$hookRunner->onMarkPatrolled( |
670 | $this->getAttribute( 'rc_id' ), $user, false, false, $tags ) |
671 | ) { |
672 | $status->fatal( 'hookaborted' ); |
673 | } |
674 | // Users without the 'autopatrol' right can't patrol their own revisions |
675 | if ( $performer->getUser()->getName() === $this->getAttribute( 'rc_user_text' ) && |
676 | !$performer->isAllowed( 'autopatrol' ) |
677 | ) { |
678 | $status->fatal( 'markedaspatrollederror-noautopatrol' ); |
679 | } |
680 | if ( !$status->isGood() ) { |
681 | return $status; |
682 | } |
683 | // If the change was patrolled already, do nothing |
684 | if ( $this->getAttribute( 'rc_patrolled' ) ) { |
685 | return $status; |
686 | } |
687 | // Attempt to set the 'patrolled' flag in RC database |
688 | $affectedRowCount = $this->reallyMarkPatrolled(); |
689 | |
690 | if ( $affectedRowCount === 0 ) { |
691 | // Query succeeded but no rows change, e.g. another request |
692 | // patrolled the same change just before us. |
693 | // Avoid duplicate log entry (T196182). |
694 | return $status; |
695 | } |
696 | |
697 | // Log this patrol event |
698 | PatrolLog::record( $this, false, $performer->getUser(), $tags ); |
699 | |
700 | $hookRunner->onMarkPatrolledComplete( |
701 | $this->getAttribute( 'rc_id' ), $user, false, false ); |
702 | |
703 | return $status; |
704 | } |
705 | |
706 | /** |
707 | * Mark this RecentChange patrolled, without error checking |
708 | * |
709 | * @return int Number of database rows changed, usually 1, but 0 if |
710 | * another request already patrolled it in the mean time. |
711 | */ |
712 | public function reallyMarkPatrolled() { |
713 | $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase(); |
714 | $dbw->newUpdateQueryBuilder() |
715 | ->update( 'recentchanges' ) |
716 | ->set( [ 'rc_patrolled' => self::PRC_PATROLLED ] ) |
717 | ->where( [ |
718 | 'rc_id' => $this->getAttribute( 'rc_id' ), |
719 | 'rc_patrolled' => self::PRC_UNPATROLLED, |
720 | ] ) |
721 | ->caller( __METHOD__ )->execute(); |
722 | $affectedRowCount = $dbw->affectedRows(); |
723 | // The change was patrolled already, do nothing |
724 | if ( $affectedRowCount === 0 ) { |
725 | return 0; |
726 | } |
727 | // Invalidate the page cache after the page has been patrolled |
728 | // to make sure that the Patrol link isn't visible any longer! |
729 | $this->getTitle()->invalidateCache(); |
730 | |
731 | // Enqueue a reverted tag update (in case the edit was a revert) |
732 | $revisionId = $this->getAttribute( 'rc_this_oldid' ); |
733 | if ( $revisionId ) { |
734 | $revertedTagUpdateManager = |
735 | MediaWikiServices::getInstance()->getRevertedTagUpdateManager(); |
736 | $revertedTagUpdateManager->approveRevertedTagForRevision( $revisionId ); |
737 | } |
738 | |
739 | return $affectedRowCount; |
740 | } |
741 | |
742 | /** |
743 | * Makes an entry in the database corresponding to an edit |
744 | * |
745 | * @since 1.36 Added $editResult parameter |
746 | * |
747 | * @param string $timestamp |
748 | * @param PageIdentity $page |
749 | * @param bool $minor |
750 | * @param UserIdentity $user |
751 | * @param string $comment |
752 | * @param int $oldId |
753 | * @param string $lastTimestamp |
754 | * @param bool $bot |
755 | * @param string $ip |
756 | * @param int $oldSize |
757 | * @param int $newSize |
758 | * @param int $newId |
759 | * @param int $patrol |
760 | * @param string[] $tags |
761 | * @param EditResult|null $editResult EditResult associated with this edit. Can be safely |
762 | * skipped if the edit is not a revert. Used only for marking revert tags. |
763 | * |
764 | * @return RecentChange |
765 | */ |
766 | public static function notifyEdit( |
767 | $timestamp, $page, $minor, $user, $comment, $oldId, $lastTimestamp, |
768 | $bot, $ip = '', $oldSize = 0, $newSize = 0, $newId = 0, $patrol = 0, |
769 | $tags = [], ?EditResult $editResult = null |
770 | ) { |
771 | Assert::parameter( $page->exists(), '$page', 'must represent an existing page' ); |
772 | |
773 | $rc = new RecentChange; |
774 | $rc->mPage = $page; |
775 | $rc->mPerformer = $user; |
776 | $rc->mAttribs = [ |
777 | 'rc_timestamp' => $timestamp, |
778 | 'rc_namespace' => $page->getNamespace(), |
779 | 'rc_title' => $page->getDBkey(), |
780 | 'rc_type' => RC_EDIT, |
781 | 'rc_source' => self::SRC_EDIT, |
782 | 'rc_minor' => $minor ? 1 : 0, |
783 | 'rc_cur_id' => $page->getId(), |
784 | 'rc_user' => $user->getId(), |
785 | 'rc_user_text' => $user->getName(), |
786 | 'rc_comment' => &$comment, |
787 | 'rc_comment_text' => &$comment, |
788 | 'rc_comment_data' => null, |
789 | 'rc_this_oldid' => (int)$newId, |
790 | 'rc_last_oldid' => $oldId, |
791 | 'rc_bot' => $bot ? 1 : 0, |
792 | 'rc_ip' => self::checkIPAddress( $ip ), |
793 | 'rc_patrolled' => intval( $patrol ), |
794 | 'rc_new' => 0, # obsolete |
795 | 'rc_old_len' => $oldSize, |
796 | 'rc_new_len' => $newSize, |
797 | 'rc_deleted' => 0, |
798 | 'rc_logid' => 0, |
799 | 'rc_log_type' => null, |
800 | 'rc_log_action' => '', |
801 | 'rc_params' => '' |
802 | ]; |
803 | |
804 | // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting. |
805 | $formatter = MediaWikiServices::getInstance()->getTitleFormatter(); |
806 | |
807 | $rc->mExtra = [ |
808 | 'prefixedDBkey' => $formatter->getPrefixedDBkey( $page ), |
809 | 'lastTimestamp' => $lastTimestamp, |
810 | 'oldSize' => $oldSize, |
811 | 'newSize' => $newSize, |
812 | 'pageStatus' => 'changed' |
813 | ]; |
814 | |
815 | $rc->addTags( $tags ); |
816 | $rc->setEditResult( $editResult ); |
817 | $rc->save(); |
818 | |
819 | return $rc; |
820 | } |
821 | |
822 | /** |
823 | * Makes an entry in the database corresponding to page creation |
824 | * @note $page must reflect the state of the database after the page creation. In particular, |
825 | * $page->getId() must return the newly assigned page ID. |
826 | * |
827 | * @param string $timestamp |
828 | * @param PageIdentity $page |
829 | * @param bool $minor |
830 | * @param UserIdentity $user |
831 | * @param string $comment |
832 | * @param bool $bot |
833 | * @param string $ip |
834 | * @param int $size |
835 | * @param int $newId |
836 | * @param int $patrol |
837 | * @param string[] $tags |
838 | * |
839 | * @return RecentChange |
840 | */ |
841 | public static function notifyNew( |
842 | $timestamp, |
843 | $page, $minor, $user, $comment, $bot, |
844 | $ip = '', $size = 0, $newId = 0, $patrol = 0, $tags = [] |
845 | ) { |
846 | Assert::parameter( $page->exists(), '$page', 'must represent an existing page' ); |
847 | |
848 | $rc = new RecentChange; |
849 | $rc->mPage = $page; |
850 | $rc->mPerformer = $user; |
851 | $rc->mAttribs = [ |
852 | 'rc_timestamp' => $timestamp, |
853 | 'rc_namespace' => $page->getNamespace(), |
854 | 'rc_title' => $page->getDBkey(), |
855 | 'rc_type' => RC_NEW, |
856 | 'rc_source' => self::SRC_NEW, |
857 | 'rc_minor' => $minor ? 1 : 0, |
858 | 'rc_cur_id' => $page->getId(), |
859 | 'rc_user' => $user->getId(), |
860 | 'rc_user_text' => $user->getName(), |
861 | 'rc_comment' => &$comment, |
862 | 'rc_comment_text' => &$comment, |
863 | 'rc_comment_data' => null, |
864 | 'rc_this_oldid' => (int)$newId, |
865 | 'rc_last_oldid' => 0, |
866 | 'rc_bot' => $bot ? 1 : 0, |
867 | 'rc_ip' => self::checkIPAddress( $ip ), |
868 | 'rc_patrolled' => intval( $patrol ), |
869 | 'rc_new' => 1, # obsolete |
870 | 'rc_old_len' => 0, |
871 | 'rc_new_len' => $size, |
872 | 'rc_deleted' => 0, |
873 | 'rc_logid' => 0, |
874 | 'rc_log_type' => null, |
875 | 'rc_log_action' => '', |
876 | 'rc_params' => '' |
877 | ]; |
878 | |
879 | // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting. |
880 | $formatter = MediaWikiServices::getInstance()->getTitleFormatter(); |
881 | |
882 | $rc->mExtra = [ |
883 | 'prefixedDBkey' => $formatter->getPrefixedDBkey( $page ), |
884 | 'lastTimestamp' => 0, |
885 | 'oldSize' => 0, |
886 | 'newSize' => $size, |
887 | 'pageStatus' => 'created' |
888 | ]; |
889 | |
890 | $rc->addTags( $tags ); |
891 | $rc->save(); |
892 | |
893 | return $rc; |
894 | } |
895 | |
896 | /** |
897 | * @param string $timestamp |
898 | * @param PageReference $logPage |
899 | * @param UserIdentity $user |
900 | * @param string $actionComment |
901 | * @param string $ip |
902 | * @param string $type |
903 | * @param string $action |
904 | * @param PageReference $target |
905 | * @param string $logComment |
906 | * @param string $params |
907 | * @param int $newId |
908 | * @param string $actionCommentIRC |
909 | * |
910 | * @return bool |
911 | */ |
912 | public static function notifyLog( $timestamp, |
913 | $logPage, $user, $actionComment, $ip, $type, |
914 | $action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = '' |
915 | ) { |
916 | $logRestrictions = MediaWikiServices::getInstance()->getMainConfig() |
917 | ->get( MainConfigNames::LogRestrictions ); |
918 | |
919 | # Don't add private logs to RC! |
920 | if ( isset( $logRestrictions[$type] ) && $logRestrictions[$type] != '*' ) { |
921 | return false; |
922 | } |
923 | $rc = self::newLogEntry( $timestamp, |
924 | $logPage, $user, $actionComment, $ip, $type, $action, |
925 | $target, $logComment, $params, $newId, $actionCommentIRC ); |
926 | $rc->save(); |
927 | |
928 | return true; |
929 | } |
930 | |
931 | /** |
932 | * @param string $timestamp |
933 | * @param PageReference $logPage |
934 | * @param UserIdentity $user |
935 | * @param string $actionComment |
936 | * @param string $ip |
937 | * @param string $type |
938 | * @param string $action |
939 | * @param PageReference $target |
940 | * @param string $logComment |
941 | * @param string $params |
942 | * @param int $newId |
943 | * @param string $actionCommentIRC |
944 | * @param int $revId Id of associated revision, if any |
945 | * @param bool $isPatrollable Whether this log entry is patrollable |
946 | * @param bool|null $forceBotFlag Override the default behavior and set bot flag to |
947 | * the value of the argument. When omitted or null, it falls back to the global state. |
948 | * |
949 | * @return RecentChange |
950 | */ |
951 | public static function newLogEntry( $timestamp, |
952 | $logPage, $user, $actionComment, $ip, |
953 | $type, $action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = '', |
954 | $revId = 0, $isPatrollable = false, $forceBotFlag = null |
955 | ) { |
956 | global $wgRequest; |
957 | |
958 | $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); |
959 | |
960 | # # Get pageStatus for email notification |
961 | switch ( $type . '-' . $action ) { |
962 | case 'delete-delete': |
963 | case 'delete-delete_redir': |
964 | case 'delete-delete_redir2': |
965 | $pageStatus = 'deleted'; |
966 | break; |
967 | case 'move-move': |
968 | case 'move-move_redir': |
969 | $pageStatus = 'moved'; |
970 | break; |
971 | case 'delete-restore': |
972 | $pageStatus = 'restored'; |
973 | break; |
974 | case 'upload-upload': |
975 | $pageStatus = 'created'; |
976 | break; |
977 | case 'upload-overwrite': |
978 | default: |
979 | $pageStatus = 'changed'; |
980 | break; |
981 | } |
982 | |
983 | // Allow unpatrolled status for patrollable log entries |
984 | $canAutopatrol = $permissionManager->userHasRight( $user, 'autopatrol' ); |
985 | $markPatrolled = $isPatrollable ? $canAutopatrol : true; |
986 | |
987 | if ( $target instanceof PageIdentity && $target->canExist() ) { |
988 | $pageId = $target->getId(); |
989 | } else { |
990 | $pageId = 0; |
991 | } |
992 | |
993 | if ( $forceBotFlag !== null ) { |
994 | $bot = (int)$forceBotFlag; |
995 | } else { |
996 | $bot = $permissionManager->userHasRight( $user, 'bot' ) ? |
997 | (int)$wgRequest->getBool( 'bot', true ) : 0; |
998 | } |
999 | |
1000 | $rc = new RecentChange; |
1001 | $rc->mPage = $target; |
1002 | $rc->mPerformer = $user; |
1003 | $rc->mAttribs = [ |
1004 | 'rc_timestamp' => $timestamp, |
1005 | 'rc_namespace' => $target->getNamespace(), |
1006 | 'rc_title' => $target->getDBkey(), |
1007 | 'rc_type' => RC_LOG, |
1008 | 'rc_source' => self::SRC_LOG, |
1009 | 'rc_minor' => 0, |
1010 | 'rc_cur_id' => $pageId, |
1011 | 'rc_user' => $user->getId(), |
1012 | 'rc_user_text' => $user->getName(), |
1013 | 'rc_comment' => &$logComment, |
1014 | 'rc_comment_text' => &$logComment, |
1015 | 'rc_comment_data' => null, |
1016 | 'rc_this_oldid' => (int)$revId, |
1017 | 'rc_last_oldid' => 0, |
1018 | 'rc_bot' => $bot, |
1019 | 'rc_ip' => self::checkIPAddress( $ip ), |
1020 | 'rc_patrolled' => $markPatrolled ? self::PRC_AUTOPATROLLED : self::PRC_UNPATROLLED, |
1021 | 'rc_new' => 0, # obsolete |
1022 | 'rc_old_len' => null, |
1023 | 'rc_new_len' => null, |
1024 | 'rc_deleted' => 0, |
1025 | 'rc_logid' => $newId, |
1026 | 'rc_log_type' => $type, |
1027 | 'rc_log_action' => $action, |
1028 | 'rc_params' => $params |
1029 | ]; |
1030 | |
1031 | // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting. |
1032 | $formatter = MediaWikiServices::getInstance()->getTitleFormatter(); |
1033 | |
1034 | $rc->mExtra = [ |
1035 | // XXX: This does not correspond to rc_namespace/rc_title/rc_cur_id. |
1036 | // Is that intentional? For all other kinds of RC entries, prefixedDBkey |
1037 | // matches rc_namespace/rc_title. Do we even need $logPage? |
1038 | 'prefixedDBkey' => $formatter->getPrefixedDBkey( $logPage ), |
1039 | 'lastTimestamp' => 0, |
1040 | 'actionComment' => $actionComment, // the comment appended to the action, passed from LogPage |
1041 | 'pageStatus' => $pageStatus, |
1042 | 'actionCommentIRC' => $actionCommentIRC |
1043 | ]; |
1044 | |
1045 | return $rc; |
1046 | } |
1047 | |
1048 | /** |
1049 | * Constructs a RecentChange object for the given categorization |
1050 | * This does not call save() on the object and thus does not write to the db |
1051 | * |
1052 | * @since 1.27 |
1053 | * |
1054 | * @param string $timestamp Timestamp of the recent change to occur |
1055 | * @param PageIdentity $categoryTitle the category a page is being added to or removed from |
1056 | * @param UserIdentity|null $user User object of the user that made the change |
1057 | * @param string $comment Change summary |
1058 | * @param PageIdentity $pageTitle the page that is being added or removed |
1059 | * @param int $oldRevId Parent revision ID of this change |
1060 | * @param int $newRevId Revision ID of this change |
1061 | * @param string $lastTimestamp Parent revision timestamp of this change |
1062 | * @param bool $bot true, if the change was made by a bot |
1063 | * @param string $ip IP address of the user, if the change was made anonymously |
1064 | * @param int $deleted Indicates whether the change has been deleted |
1065 | * @param bool|null $added true, if the category was added, false for removed |
1066 | * @param bool $forImport Whether the associated revision was imported |
1067 | * |
1068 | * @return RecentChange |
1069 | */ |
1070 | public static function newForCategorization( |
1071 | $timestamp, |
1072 | PageIdentity $categoryTitle, |
1073 | ?UserIdentity $user, |
1074 | $comment, |
1075 | PageIdentity $pageTitle, |
1076 | $oldRevId, |
1077 | $newRevId, |
1078 | $lastTimestamp, |
1079 | $bot, |
1080 | $ip = '', |
1081 | $deleted = 0, |
1082 | $added = null, |
1083 | bool $forImport = false |
1084 | ) { |
1085 | // Done in a backwards compatible way. |
1086 | $categoryWikiPage = MediaWikiServices::getInstance()->getWikiPageFactory() |
1087 | ->newFromTitle( $categoryTitle ); |
1088 | |
1089 | '@phan-var WikiCategoryPage $categoryWikiPage'; |
1090 | $params = [ |
1091 | 'hidden-cat' => $categoryWikiPage->isHidden() |
1092 | ]; |
1093 | if ( $added !== null ) { |
1094 | $params['added'] = $added; |
1095 | } |
1096 | |
1097 | if ( !$user ) { |
1098 | // XXX: when and why do we need this? |
1099 | $user = MediaWikiServices::getInstance()->getActorStore()->getUnknownActor(); |
1100 | } |
1101 | |
1102 | $rc = new RecentChange; |
1103 | $rc->mPage = $categoryTitle; |
1104 | $rc->mPerformer = $user; |
1105 | $rc->mAttribs = [ |
1106 | 'rc_timestamp' => MWTimestamp::convert( TS_MW, $timestamp ), |
1107 | 'rc_namespace' => $categoryTitle->getNamespace(), |
1108 | 'rc_title' => $categoryTitle->getDBkey(), |
1109 | 'rc_type' => RC_CATEGORIZE, |
1110 | 'rc_source' => self::SRC_CATEGORIZE, |
1111 | 'rc_minor' => 0, |
1112 | // XXX: rc_cur_id does not correspond to rc_namespace/rc_title. |
1113 | // It's because when the page (rc_cur_id) is deleted, we want |
1114 | // to delete the categorization entries, too (see LinksDeletionUpdate). |
1115 | 'rc_cur_id' => $pageTitle->getId(), |
1116 | 'rc_user' => $user->getId(), |
1117 | 'rc_user_text' => $user->getName(), |
1118 | 'rc_comment' => &$comment, |
1119 | 'rc_comment_text' => &$comment, |
1120 | 'rc_comment_data' => null, |
1121 | 'rc_this_oldid' => (int)$newRevId, |
1122 | 'rc_last_oldid' => $oldRevId, |
1123 | 'rc_bot' => $bot ? 1 : 0, |
1124 | 'rc_ip' => self::checkIPAddress( $ip ), |
1125 | 'rc_patrolled' => self::PRC_AUTOPATROLLED, // Always patrolled, just like log entries |
1126 | 'rc_new' => 0, # obsolete |
1127 | 'rc_old_len' => null, |
1128 | 'rc_new_len' => null, |
1129 | 'rc_deleted' => $deleted, |
1130 | 'rc_logid' => 0, |
1131 | 'rc_log_type' => null, |
1132 | 'rc_log_action' => '', |
1133 | 'rc_params' => serialize( $params ) |
1134 | ]; |
1135 | |
1136 | // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting. |
1137 | $formatter = MediaWikiServices::getInstance()->getTitleFormatter(); |
1138 | |
1139 | $rc->mExtra = [ |
1140 | 'prefixedDBkey' => $formatter->getPrefixedDBkey( $categoryTitle ), |
1141 | 'lastTimestamp' => $lastTimestamp, |
1142 | 'oldSize' => 0, |
1143 | 'newSize' => 0, |
1144 | 'pageStatus' => 'changed', |
1145 | 'forImport' => $forImport, |
1146 | ]; |
1147 | |
1148 | return $rc; |
1149 | } |
1150 | |
1151 | /** |
1152 | * Get a parameter value |
1153 | * |
1154 | * @since 1.27 |
1155 | * |
1156 | * @param string $name parameter name |
1157 | * @return mixed |
1158 | */ |
1159 | public function getParam( $name ) { |
1160 | $params = $this->parseParams(); |
1161 | return $params[$name] ?? null; |
1162 | } |
1163 | |
1164 | /** |
1165 | * Initialises the members of this object from a mysql row object |
1166 | * |
1167 | * @param mixed $row |
1168 | */ |
1169 | public function loadFromRow( $row ) { |
1170 | $this->mAttribs = get_object_vars( $row ); |
1171 | $this->mAttribs['rc_timestamp'] = wfTimestamp( TS_MW, $this->mAttribs['rc_timestamp'] ); |
1172 | // rc_deleted MUST be set |
1173 | $this->mAttribs['rc_deleted'] = $row->rc_deleted; |
1174 | |
1175 | $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
1176 | $comment = MediaWikiServices::getInstance()->getCommentStore() |
1177 | // Legacy because $row may have come from self::selectFields() |
1178 | ->getCommentLegacy( $dbr, 'rc_comment', $row, true ) |
1179 | ->text; |
1180 | $this->mAttribs['rc_comment'] = &$comment; |
1181 | $this->mAttribs['rc_comment_text'] = &$comment; |
1182 | $this->mAttribs['rc_comment_data'] = null; |
1183 | |
1184 | $this->mPerformer = $this->getUserIdentityFromAnyId( |
1185 | $row->rc_user ?? null, |
1186 | $row->rc_user_text ?? null, |
1187 | $row->rc_actor ?? null |
1188 | ); |
1189 | $this->mAttribs['rc_user'] = $this->mPerformer->getId(); |
1190 | $this->mAttribs['rc_user_text'] = $this->mPerformer->getName(); |
1191 | |
1192 | // Watchlist expiry. |
1193 | if ( isset( $row->we_expiry ) && $row->we_expiry ) { |
1194 | $this->watchlistExpiry = wfTimestamp( TS_MW, $row->we_expiry ); |
1195 | } |
1196 | } |
1197 | |
1198 | /** |
1199 | * Get an attribute value |
1200 | * |
1201 | * @param string $name Attribute name |
1202 | * @return mixed |
1203 | */ |
1204 | public function getAttribute( $name ) { |
1205 | if ( $name === 'rc_comment' ) { |
1206 | return MediaWikiServices::getInstance()->getCommentStore() |
1207 | ->getComment( 'rc_comment', $this->mAttribs, true )->text; |
1208 | } |
1209 | |
1210 | if ( $name === 'rc_user' || $name === 'rc_user_text' || $name === 'rc_actor' ) { |
1211 | $user = $this->getPerformerIdentity(); |
1212 | |
1213 | if ( $name === 'rc_user' ) { |
1214 | return $user->getId(); |
1215 | } |
1216 | if ( $name === 'rc_user_text' ) { |
1217 | return $user->getName(); |
1218 | } |
1219 | if ( $name === 'rc_actor' ) { |
1220 | // NOTE: rc_actor exists in the database, but application logic should not use it. |
1221 | wfDeprecatedMsg( 'Accessing deprecated field rc_actor', '1.36' ); |
1222 | $actorStore = MediaWikiServices::getInstance()->getActorStore(); |
1223 | $db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
1224 | return $actorStore->findActorId( $user, $db ); |
1225 | } |
1226 | } |
1227 | |
1228 | return $this->mAttribs[$name] ?? null; |
1229 | } |
1230 | |
1231 | /** |
1232 | * @return array |
1233 | */ |
1234 | public function getAttributes() { |
1235 | return $this->mAttribs; |
1236 | } |
1237 | |
1238 | /** |
1239 | * Gets the end part of the diff URL associated with this object |
1240 | * Blank if no diff link should be displayed |
1241 | * @param bool $forceCur |
1242 | * @return string |
1243 | */ |
1244 | public function diffLinkTrail( $forceCur ) { |
1245 | if ( $this->mAttribs['rc_type'] == RC_EDIT ) { |
1246 | $trail = "curid=" . (int)( $this->mAttribs['rc_cur_id'] ) . |
1247 | "&oldid=" . (int)( $this->mAttribs['rc_last_oldid'] ); |
1248 | if ( $forceCur ) { |
1249 | $trail .= '&diff=0'; |
1250 | } else { |
1251 | $trail .= '&diff=' . (int)( $this->mAttribs['rc_this_oldid'] ); |
1252 | } |
1253 | } else { |
1254 | $trail = ''; |
1255 | } |
1256 | |
1257 | return $trail; |
1258 | } |
1259 | |
1260 | /** |
1261 | * Returns the change size (HTML). |
1262 | * The lengths can be given optionally. |
1263 | * @param int $old |
1264 | * @param int $new |
1265 | * @return string |
1266 | */ |
1267 | public function getCharacterDifference( $old = 0, $new = 0 ) { |
1268 | if ( $old === 0 ) { |
1269 | $old = $this->mAttribs['rc_old_len']; |
1270 | } |
1271 | if ( $new === 0 ) { |
1272 | $new = $this->mAttribs['rc_new_len']; |
1273 | } |
1274 | if ( $old === null || $new === null ) { |
1275 | return ''; |
1276 | } |
1277 | |
1278 | return ChangesList::showCharacterDifference( $old, $new ); |
1279 | } |
1280 | |
1281 | private static function checkIPAddress( $ip ) { |
1282 | global $wgRequest; |
1283 | |
1284 | if ( $ip ) { |
1285 | if ( !IPUtils::isIPAddress( $ip ) ) { |
1286 | throw new RuntimeException( "Attempt to write \"" . $ip . |
1287 | "\" as an IP address into recent changes" ); |
1288 | } |
1289 | } else { |
1290 | $ip = $wgRequest->getIP(); |
1291 | if ( !$ip ) { |
1292 | $ip = ''; |
1293 | } |
1294 | } |
1295 | |
1296 | return $ip; |
1297 | } |
1298 | |
1299 | /** |
1300 | * Check whether the given timestamp is new enough to have a RC row with a given tolerance |
1301 | * as the recentchanges table might not be cleared out regularly (so older entries might exist) |
1302 | * or rows which will be deleted soon shouldn't be included. |
1303 | * |
1304 | * @param mixed $timestamp MWTimestamp compatible timestamp |
1305 | * @param int $tolerance Tolerance in seconds |
1306 | * @return bool |
1307 | */ |
1308 | public static function isInRCLifespan( $timestamp, $tolerance = 0 ) { |
1309 | $rcMaxAge = |
1310 | MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::RCMaxAge ); |
1311 | |
1312 | return (int)wfTimestamp( TS_UNIX, $timestamp ) > time() - $tolerance - $rcMaxAge; |
1313 | } |
1314 | |
1315 | /** |
1316 | * Whether e-mail notifications are generally enabled on this wiki. |
1317 | * |
1318 | * This is used for: |
1319 | * |
1320 | * - performance optimization in RecentChange::save(). |
1321 | * After an edit, whether or not we need to use the EmailNotification |
1322 | * service to determine which EnotifNotifyJob to dispatch. |
1323 | * |
1324 | * - performance optmization in WatchlistManager. |
1325 | * After using reset ("Mark all pages as seen") on Special:Watchlist, |
1326 | * whether to only look for user talk data to reset, or whether to look |
1327 | * at all possible pages for timestamps to reset. |
1328 | * |
1329 | * TODO: Determine whether these optimizations still make sense. |
1330 | * |
1331 | * FIXME: The $wgShowUpdatedMarker variable was added to this condtion |
1332 | * in 2008 (2cf12c973d, SVN r35001) because at the time the per-user |
1333 | * "last seen" marker for watchlist and page history, was managed by |
1334 | * the EmailNotification/UserMailed classes. As of August 2022, this |
1335 | * appears to no longer be the case. |
1336 | * |
1337 | * @since 1.40 |
1338 | * @param Config $conf |
1339 | * @return bool |
1340 | */ |
1341 | public static function isEnotifEnabled( Config $conf ): bool { |
1342 | return $conf->get( MainConfigNames::EnotifUserTalk ) || |
1343 | $conf->get( MainConfigNames::EnotifWatchlist ) || |
1344 | $conf->get( MainConfigNames::ShowUpdatedMarker ); |
1345 | } |
1346 | |
1347 | /** |
1348 | * Get the extra URL that is given as part of the notification to RCFeed consumers. |
1349 | * |
1350 | * This is mainly to facilitate patrolling or other content review. |
1351 | * |
1352 | * @since 1.40 |
1353 | * @return string|null URL |
1354 | */ |
1355 | public function getNotifyUrl() { |
1356 | $services = MediaWikiServices::getInstance(); |
1357 | $mainConfig = $services->getMainConfig(); |
1358 | $useRCPatrol = $mainConfig->get( MainConfigNames::UseRCPatrol ); |
1359 | $useNPPatrol = $mainConfig->get( MainConfigNames::UseNPPatrol ); |
1360 | $canonicalServer = $mainConfig->get( MainConfigNames::CanonicalServer ); |
1361 | $script = $mainConfig->get( MainConfigNames::Script ); |
1362 | |
1363 | $type = $this->getAttribute( 'rc_type' ); |
1364 | if ( $type == RC_LOG ) { |
1365 | $url = null; |
1366 | } else { |
1367 | $url = $canonicalServer . $script; |
1368 | if ( $type == RC_NEW ) { |
1369 | $query = '?oldid=' . $this->getAttribute( 'rc_this_oldid' ); |
1370 | } else { |
1371 | $query = '?diff=' . $this->getAttribute( 'rc_this_oldid' ) |
1372 | . '&oldid=' . $this->getAttribute( 'rc_last_oldid' ); |
1373 | } |
1374 | if ( $useRCPatrol || ( $this->getAttribute( 'rc_type' ) == RC_NEW && $useNPPatrol ) ) { |
1375 | $query .= '&rcid=' . $this->getAttribute( 'rc_id' ); |
1376 | } |
1377 | |
1378 | ( new HookRunner( $services->getHookContainer() ) )->onIRCLineURL( $url, $query, $this ); |
1379 | $url .= $query; |
1380 | } |
1381 | |
1382 | return $url; |
1383 | } |
1384 | |
1385 | /** |
1386 | * Parses and returns the rc_params attribute |
1387 | * |
1388 | * @since 1.26 |
1389 | * @return mixed|bool false on failed unserialization |
1390 | */ |
1391 | public function parseParams() { |
1392 | $rcParams = $this->getAttribute( 'rc_params' ); |
1393 | |
1394 | AtEase::suppressWarnings(); |
1395 | $unserializedParams = unserialize( $rcParams ); |
1396 | AtEase::restoreWarnings(); |
1397 | |
1398 | return $unserializedParams; |
1399 | } |
1400 | |
1401 | /** |
1402 | * Tags to append to the recent change, |
1403 | * and associated revision/log |
1404 | * |
1405 | * @since 1.28 |
1406 | * |
1407 | * @param string|string[] $tags |
1408 | */ |
1409 | public function addTags( $tags ) { |
1410 | if ( is_string( $tags ) ) { |
1411 | $this->tags[] = $tags; |
1412 | } else { |
1413 | $this->tags = array_merge( $tags, $this->tags ); |
1414 | } |
1415 | } |
1416 | |
1417 | /** |
1418 | * Sets the EditResult associated with the edit. |
1419 | * |
1420 | * @since 1.36 |
1421 | * |
1422 | * @param EditResult|null $editResult |
1423 | */ |
1424 | public function setEditResult( ?EditResult $editResult ) { |
1425 | $this->editResult = $editResult; |
1426 | } |
1427 | |
1428 | /** |
1429 | * @param string|int|null $userId |
1430 | * @param string|null $userName |
1431 | * @param string|int|null $actorId |
1432 | * |
1433 | * @return UserIdentity |
1434 | */ |
1435 | private function getUserIdentityFromAnyId( |
1436 | $userId, |
1437 | $userName, |
1438 | $actorId = null |
1439 | ): UserIdentity { |
1440 | // XXX: Is this logic needed elsewhere? Should it be reusable? |
1441 | |
1442 | $userId = isset( $userId ) ? (int)$userId : null; |
1443 | $actorId = isset( $actorId ) ? (int)$actorId : 0; |
1444 | |
1445 | $actorStore = MediaWikiServices::getInstance()->getActorStore(); |
1446 | if ( $userName && $actorId ) { |
1447 | // Likely the fields are coming from a join on actor table, |
1448 | // so can definitely build a UserIdentityValue. |
1449 | return $actorStore->newActorFromRowFields( $userId, $userName, $actorId ); |
1450 | } |
1451 | if ( $userId !== null ) { |
1452 | if ( $userName !== null ) { |
1453 | // NOTE: For IPs and external users, $userId will be 0. |
1454 | $user = new UserIdentityValue( $userId, $userName ); |
1455 | } else { |
1456 | $user = $actorStore->getUserIdentityByUserId( $userId ); |
1457 | |
1458 | if ( !$user ) { |
1459 | throw new RuntimeException( "User not found by ID: $userId" ); |
1460 | } |
1461 | } |
1462 | } elseif ( $actorId > 0 ) { |
1463 | $db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase(); |
1464 | $user = $actorStore->getActorById( $actorId, $db ); |
1465 | |
1466 | if ( !$user ) { |
1467 | throw new RuntimeException( "User not found by actor ID: $actorId" ); |
1468 | } |
1469 | } elseif ( $userName !== null ) { |
1470 | $user = $actorStore->getUserIdentityByName( $userName ); |
1471 | |
1472 | if ( !$user ) { |
1473 | throw new RuntimeException( "User not found by name: $userName" ); |
1474 | } |
1475 | } else { |
1476 | throw new RuntimeException( 'At least one of user ID, actor ID or user name must be given' ); |
1477 | } |
1478 | |
1479 | return $user; |
1480 | } |
1481 | } |