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