Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
83.47% |
313 / 375 |
|
16.67% |
4 / 24 |
CRAP | |
0.00% |
0 / 1 |
ContribsPager | |
83.69% |
313 / 374 |
|
16.67% |
4 / 24 |
147.14 | |
0.00% |
0 / 1 |
__construct | |
95.65% |
44 / 46 |
|
0.00% |
0 / 1 |
8 | |||
getDefaultQuery | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
reallyDoQuery | |
96.15% |
25 / 26 |
|
0.00% |
0 / 1 |
6 | |||
getTargetTable | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getQueryInfo | |
91.84% |
45 / 49 |
|
0.00% |
0 / 1 |
10.05 | |||
getNamespaceCond | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
5 | |||
getIpRangeConds | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
isQueryableRange | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
6 | |||
getIndexField | |
55.56% |
5 / 9 |
|
0.00% |
0 / 1 |
5.40 | |||
getTagFilter | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getTarget | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isNewOnly | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getNamespace | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getExtraSortFields | |
55.56% |
5 / 9 |
|
0.00% |
0 / 1 |
5.40 | |||
doBatchLookups | |
92.00% |
23 / 25 |
|
0.00% |
0 / 1 |
6.02 | |||
getStartBody | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEndBody | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
tryCreatingRevisionRecord | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
5.12 | |||
formatRow | |
82.27% |
116 / 141 |
|
0.00% |
0 / 1 |
27.21 | |||
getSqlComment | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
preventClickjacking | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setPreventClickjacking | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPreventClickjacking | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
processDateFilter | |
82.35% |
14 / 17 |
|
0.00% |
0 / 1 |
6.20 |
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 | * @ingroup Pager |
20 | */ |
21 | |
22 | namespace MediaWiki\Pager; |
23 | |
24 | use ChangesList; |
25 | use ChangeTags; |
26 | use DateTime; |
27 | use HtmlArmor; |
28 | use InvalidArgumentException; |
29 | use MapCacheLRU; |
30 | use MediaWiki\Cache\LinkBatchFactory; |
31 | use MediaWiki\CommentFormatter\CommentFormatter; |
32 | use MediaWiki\Config\Config; |
33 | use MediaWiki\Context\IContextSource; |
34 | use MediaWiki\HookContainer\HookContainer; |
35 | use MediaWiki\HookContainer\HookRunner; |
36 | use MediaWiki\Html\Html; |
37 | use MediaWiki\Html\TemplateParser; |
38 | use MediaWiki\Linker\Linker; |
39 | use MediaWiki\Linker\LinkRenderer; |
40 | use MediaWiki\MainConfigNames; |
41 | use MediaWiki\MediaWikiServices; |
42 | use MediaWiki\Parser\Sanitizer; |
43 | use MediaWiki\Revision\RevisionRecord; |
44 | use MediaWiki\Revision\RevisionStore; |
45 | use MediaWiki\Title\NamespaceInfo; |
46 | use MediaWiki\Title\Title; |
47 | use MediaWiki\User\UserIdentity; |
48 | use MediaWiki\User\UserRigorOptions; |
49 | use stdClass; |
50 | use Wikimedia\IPUtils; |
51 | use Wikimedia\Rdbms\FakeResultWrapper; |
52 | use Wikimedia\Rdbms\IConnectionProvider; |
53 | use Wikimedia\Rdbms\IReadableDatabase; |
54 | use Wikimedia\Rdbms\IResultWrapper; |
55 | |
56 | /** |
57 | * Pager for Special:Contributions |
58 | * @ingroup Pager |
59 | */ |
60 | class ContribsPager extends RangeChronologicalPager { |
61 | |
62 | public $mGroupByDate = true; |
63 | |
64 | /** |
65 | * @var string[] Local cache for escaped messages |
66 | */ |
67 | private $messages; |
68 | |
69 | /** |
70 | * @var string User name, or a string describing an IP address range |
71 | */ |
72 | private $target; |
73 | |
74 | /** |
75 | * @var string|int A single namespace number, or an empty string for all namespaces |
76 | */ |
77 | private $namespace; |
78 | |
79 | /** |
80 | * @var string[]|false Name of tag to filter, or false to ignore tags |
81 | */ |
82 | private $tagFilter; |
83 | |
84 | /** |
85 | * @var bool Set to true to invert the tag selection |
86 | */ |
87 | private $tagInvert; |
88 | |
89 | /** |
90 | * @var bool Set to true to invert the namespace selection |
91 | */ |
92 | private $nsInvert; |
93 | |
94 | /** |
95 | * @var bool Set to true to show both the subject and talk namespace, no matter which got |
96 | * selected |
97 | */ |
98 | private $associated; |
99 | |
100 | /** |
101 | * @var bool Set to true to show only deleted revisions |
102 | */ |
103 | private $deletedOnly; |
104 | |
105 | /** |
106 | * @var bool Set to true to show only latest (a.k.a. current) revisions |
107 | */ |
108 | private $topOnly; |
109 | |
110 | /** |
111 | * @var bool Set to true to show only new pages |
112 | */ |
113 | private $newOnly; |
114 | |
115 | /** |
116 | * @var bool Set to true to hide edits marked as minor by the user |
117 | */ |
118 | private $hideMinor; |
119 | |
120 | /** |
121 | * @var bool Set to true to only include mediawiki revisions. |
122 | * (restricts extensions from executing additional queries to include their own contributions) |
123 | */ |
124 | private $revisionsOnly; |
125 | |
126 | private $preventClickjacking = false; |
127 | |
128 | /** |
129 | * @var array |
130 | */ |
131 | private $mParentLens; |
132 | |
133 | /** @var UserIdentity */ |
134 | private $targetUser; |
135 | |
136 | private TemplateParser $templateParser; |
137 | private CommentFormatter $commentFormatter; |
138 | private HookRunner $hookRunner; |
139 | private LinkBatchFactory $linkBatchFactory; |
140 | private NamespaceInfo $namespaceInfo; |
141 | private RevisionStore $revisionStore; |
142 | |
143 | /** @var string[] */ |
144 | private $formattedComments = []; |
145 | |
146 | /** @var RevisionRecord[] Cached revisions by ID */ |
147 | private $revisions = []; |
148 | |
149 | /** @var MapCacheLRU */ |
150 | private $tagsCache; |
151 | |
152 | /** |
153 | * FIXME List services first T266484 / T290405 |
154 | * @param IContextSource $context |
155 | * @param array $options |
156 | * @param LinkRenderer|null $linkRenderer |
157 | * @param LinkBatchFactory|null $linkBatchFactory |
158 | * @param HookContainer|null $hookContainer |
159 | * @param IConnectionProvider|null $dbProvider |
160 | * @param RevisionStore|null $revisionStore |
161 | * @param NamespaceInfo|null $namespaceInfo |
162 | * @param UserIdentity|null $targetUser |
163 | * @param CommentFormatter|null $commentFormatter |
164 | */ |
165 | public function __construct( |
166 | IContextSource $context, |
167 | array $options, |
168 | LinkRenderer $linkRenderer = null, |
169 | LinkBatchFactory $linkBatchFactory = null, |
170 | HookContainer $hookContainer = null, |
171 | IConnectionProvider $dbProvider = null, |
172 | RevisionStore $revisionStore = null, |
173 | NamespaceInfo $namespaceInfo = null, |
174 | UserIdentity $targetUser = null, |
175 | CommentFormatter $commentFormatter = null |
176 | ) { |
177 | // Class is used directly in extensions - T266484 |
178 | $services = MediaWikiServices::getInstance(); |
179 | $dbProvider ??= $services->getConnectionProvider(); |
180 | |
181 | // Set ->target before calling parent::__construct() so |
182 | // parent can call $this->getIndexField() and get the right result. Set |
183 | // the rest too just to keep things simple. |
184 | if ( $targetUser ) { |
185 | $this->target = $options['target'] ?? $targetUser->getName(); |
186 | $this->targetUser = $targetUser; |
187 | } else { |
188 | // Use target option |
189 | // It's possible for the target to be empty. This is used by |
190 | // ContribsPagerTest and does not cause newFromName() to return |
191 | // false. It's probably not used by any production code. |
192 | $this->target = $options['target'] ?? ''; |
193 | // @phan-suppress-next-line PhanPossiblyNullTypeMismatchProperty RIGOR_NONE never returns null |
194 | $this->targetUser = $services->getUserFactory()->newFromName( |
195 | $this->target, UserRigorOptions::RIGOR_NONE |
196 | ); |
197 | if ( !$this->targetUser ) { |
198 | // This can happen if the target contained "#". Callers |
199 | // typically pass user input through title normalization to |
200 | // avoid it. |
201 | throw new InvalidArgumentException( __METHOD__ . ': the user name is too ' . |
202 | 'broken to use even with validation disabled.' ); |
203 | } |
204 | } |
205 | |
206 | $this->namespace = $options['namespace'] ?? ''; |
207 | $this->tagFilter = $options['tagfilter'] ?? false; |
208 | $this->tagInvert = $options['tagInvert'] ?? false; |
209 | $this->nsInvert = $options['nsInvert'] ?? false; |
210 | $this->associated = $options['associated'] ?? false; |
211 | |
212 | $this->deletedOnly = !empty( $options['deletedOnly'] ); |
213 | $this->topOnly = !empty( $options['topOnly'] ); |
214 | $this->newOnly = !empty( $options['newOnly'] ); |
215 | $this->hideMinor = !empty( $options['hideMinor'] ); |
216 | $this->revisionsOnly = !empty( $options['revisionsOnly'] ); |
217 | |
218 | parent::__construct( $context, $linkRenderer ?? $services->getLinkRenderer() ); |
219 | |
220 | $msgs = [ |
221 | 'diff', |
222 | 'hist', |
223 | 'pipe-separator', |
224 | 'uctop', |
225 | 'changeslist-nocomment', |
226 | ]; |
227 | |
228 | foreach ( $msgs as $msg ) { |
229 | $this->messages[$msg] = $this->msg( $msg )->escaped(); |
230 | } |
231 | |
232 | // Date filtering: use timestamp if available |
233 | $startTimestamp = ''; |
234 | $endTimestamp = ''; |
235 | if ( isset( $options['start'] ) && $options['start'] ) { |
236 | $startTimestamp = $options['start'] . ' 00:00:00'; |
237 | } |
238 | if ( isset( $options['end'] ) && $options['end'] ) { |
239 | $endTimestamp = $options['end'] . ' 23:59:59'; |
240 | } |
241 | $this->getDateRangeCond( $startTimestamp, $endTimestamp ); |
242 | |
243 | $this->templateParser = new TemplateParser(); |
244 | $this->linkBatchFactory = $linkBatchFactory ?? $services->getLinkBatchFactory(); |
245 | $this->hookRunner = new HookRunner( $hookContainer ?? $services->getHookContainer() ); |
246 | $this->revisionStore = $revisionStore ?? $services->getRevisionStore(); |
247 | $this->namespaceInfo = $namespaceInfo ?? $services->getNamespaceInfo(); |
248 | $this->commentFormatter = $commentFormatter ?? $services->getCommentFormatter(); |
249 | $this->tagsCache = new MapCacheLRU( 50 ); |
250 | } |
251 | |
252 | public function getDefaultQuery() { |
253 | $query = parent::getDefaultQuery(); |
254 | $query['target'] = $this->target; |
255 | |
256 | return $query; |
257 | } |
258 | |
259 | /** |
260 | * This method basically executes the exact same code as the parent class, though with |
261 | * a hook added, to allow extensions to add additional queries. |
262 | * |
263 | * @param string $offset Index offset, inclusive |
264 | * @param int $limit Exact query limit |
265 | * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING |
266 | * @return IResultWrapper |
267 | */ |
268 | public function reallyDoQuery( $offset, $limit, $order ) { |
269 | [ $tables, $fields, $conds, $fname, $options, $join_conds ] = $this->buildQueryInfo( |
270 | $offset, |
271 | $limit, |
272 | $order |
273 | ); |
274 | |
275 | $options['MAX_EXECUTION_TIME'] = |
276 | $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries ); |
277 | /* |
278 | * This hook will allow extensions to add in additional queries, so they can get their data |
279 | * in My Contributions as well. Extensions should append their results to the $data array. |
280 | * |
281 | * Extension queries have to implement the navbar requirement as well. They should |
282 | * - have a column aliased as $pager->getIndexField() |
283 | * - have LIMIT set |
284 | * - have a WHERE-clause that compares the $pager->getIndexField()-equivalent column to the offset |
285 | * - have the ORDER BY specified based upon the details provided by the navbar |
286 | * |
287 | * See includes/Pager.php buildQueryInfo() method on how to build LIMIT, WHERE & ORDER BY |
288 | * |
289 | * &$data: an array of results of all contribs queries |
290 | * $pager: the ContribsPager object hooked into |
291 | * $offset: see phpdoc above |
292 | * $limit: see phpdoc above |
293 | * $descending: see phpdoc above |
294 | */ |
295 | $dbr = $this->getDatabase(); |
296 | $data = [ $dbr->select( |
297 | $tables, $fields, $conds, $fname, $options, $join_conds |
298 | ) ]; |
299 | if ( !$this->revisionsOnly ) { |
300 | // TODO: Range offsets are fairly important and all handlers should take care of it. |
301 | // If this hook will be replaced (e.g. unified with the DeletedContribsPager one), |
302 | // please consider passing [ $this->endOffset, $this->startOffset ] to it (T167577). |
303 | $this->hookRunner->onContribsPager__reallyDoQuery( |
304 | $data, $this, $offset, $limit, $order ); |
305 | } |
306 | |
307 | $result = []; |
308 | |
309 | // loop all results and collect them in an array |
310 | foreach ( $data as $query ) { |
311 | foreach ( $query as $i => $row ) { |
312 | // If the query results are in descending order, the indexes must also be in descending order |
313 | $index = $order === self::QUERY_ASCENDING ? $i : $limit - 1 - $i; |
314 | // Left-pad with zeroes, because these values will be sorted as strings |
315 | $index = str_pad( (string)$index, strlen( (string)$limit ), '0', STR_PAD_LEFT ); |
316 | // use index column as key, allowing us to easily sort in PHP |
317 | $result[$row->{$this->getIndexField()} . "-$index"] = $row; |
318 | } |
319 | } |
320 | |
321 | // sort results |
322 | if ( $order === self::QUERY_ASCENDING ) { |
323 | ksort( $result ); |
324 | } else { |
325 | krsort( $result ); |
326 | } |
327 | |
328 | // enforce limit |
329 | $result = array_slice( $result, 0, $limit ); |
330 | |
331 | // get rid of array keys |
332 | $result = array_values( $result ); |
333 | |
334 | return new FakeResultWrapper( $result ); |
335 | } |
336 | |
337 | /** |
338 | * Return the table targeted for ordering and continuation |
339 | * |
340 | * See T200259 and T221380. |
341 | * |
342 | * @warning Keep this in sync with self::getQueryInfo()! |
343 | * |
344 | * @return string |
345 | */ |
346 | private function getTargetTable() { |
347 | $dbr = $this->getDatabase(); |
348 | $ipRangeConds = $this->targetUser->isRegistered() |
349 | ? null : $this->getIpRangeConds( $dbr, $this->target ); |
350 | if ( $ipRangeConds ) { |
351 | return 'ip_changes'; |
352 | } |
353 | |
354 | return 'revision'; |
355 | } |
356 | |
357 | public function getQueryInfo() { |
358 | $revQuery = $this->revisionStore->getQueryInfo( [ 'page', 'user' ] ); |
359 | $queryInfo = [ |
360 | 'tables' => $revQuery['tables'], |
361 | 'fields' => array_merge( $revQuery['fields'], [ 'page_is_new' ] ), |
362 | 'conds' => [], |
363 | 'options' => [], |
364 | 'join_conds' => $revQuery['joins'], |
365 | ]; |
366 | |
367 | // WARNING: Keep this in sync with getTargetTable()! |
368 | $dbr = $this->getDatabase(); |
369 | $ipRangeConds = !$this->targetUser->isRegistered() ? $this->getIpRangeConds( $dbr, $this->target ) : null; |
370 | if ( $ipRangeConds ) { |
371 | // Put ip_changes first (T284419) |
372 | array_unshift( $queryInfo['tables'], 'ip_changes' ); |
373 | $queryInfo['join_conds']['revision'] = [ |
374 | 'JOIN', [ 'rev_id = ipc_rev_id' ] |
375 | ]; |
376 | $queryInfo['conds'][] = $ipRangeConds; |
377 | } else { |
378 | $queryInfo['conds']['actor_name'] = $this->targetUser->getName(); |
379 | // Force the appropriate index to avoid bad query plans (T307295) |
380 | $queryInfo['options']['USE INDEX']['revision'] = 'rev_actor_timestamp'; |
381 | } |
382 | |
383 | if ( $this->deletedOnly ) { |
384 | $queryInfo['conds'][] = 'rev_deleted != 0'; |
385 | } |
386 | |
387 | if ( $this->topOnly ) { |
388 | $queryInfo['conds'][] = 'rev_id = page_latest'; |
389 | } |
390 | |
391 | if ( $this->newOnly ) { |
392 | $queryInfo['conds'][] = 'rev_parent_id = 0'; |
393 | } |
394 | |
395 | if ( $this->hideMinor ) { |
396 | $queryInfo['conds'][] = 'rev_minor_edit = 0'; |
397 | } |
398 | |
399 | $queryInfo['conds'] = array_merge( $queryInfo['conds'], $this->getNamespaceCond() ); |
400 | |
401 | // Paranoia: avoid brute force searches (T19342) |
402 | if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) { |
403 | $queryInfo['conds'][] = $dbr->bitAnd( |
404 | 'rev_deleted', RevisionRecord::DELETED_USER |
405 | ) . ' = 0'; |
406 | } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { |
407 | $queryInfo['conds'][] = $dbr->bitAnd( |
408 | 'rev_deleted', RevisionRecord::SUPPRESSED_USER |
409 | ) . ' != ' . RevisionRecord::SUPPRESSED_USER; |
410 | } |
411 | |
412 | // $this->getIndexField() must be in the result rows, as reallyDoQuery() tries to access it. |
413 | $indexField = $this->getIndexField(); |
414 | if ( $indexField !== 'rev_timestamp' ) { |
415 | $queryInfo['fields'][] = $indexField; |
416 | } |
417 | |
418 | ChangeTags::modifyDisplayQuery( |
419 | $queryInfo['tables'], |
420 | $queryInfo['fields'], |
421 | $queryInfo['conds'], |
422 | $queryInfo['join_conds'], |
423 | $queryInfo['options'], |
424 | $this->tagFilter, |
425 | $this->tagInvert, |
426 | ); |
427 | |
428 | $this->hookRunner->onContribsPager__getQueryInfo( $this, $queryInfo ); |
429 | |
430 | return $queryInfo; |
431 | } |
432 | |
433 | protected function getNamespaceCond() { |
434 | if ( $this->namespace !== '' ) { |
435 | $dbr = $this->getDatabase(); |
436 | $selectedNS = $dbr->addQuotes( $this->namespace ); |
437 | $eq_op = $this->nsInvert ? '!=' : '='; |
438 | $bool_op = $this->nsInvert ? 'AND' : 'OR'; |
439 | |
440 | if ( !$this->associated ) { |
441 | return [ "page_namespace $eq_op $selectedNS" ]; |
442 | } |
443 | |
444 | $associatedNS = $dbr->addQuotes( $this->namespaceInfo->getAssociated( $this->namespace ) ); |
445 | |
446 | return [ |
447 | "page_namespace $eq_op $selectedNS " . |
448 | $bool_op . |
449 | " page_namespace $eq_op $associatedNS" |
450 | ]; |
451 | } |
452 | |
453 | return []; |
454 | } |
455 | |
456 | /** |
457 | * Get SQL conditions for an IP range, if applicable |
458 | * @param IReadableDatabase $db |
459 | * @param string $ip The IP address or CIDR |
460 | * @return string|false SQL for valid IP ranges, false if invalid |
461 | */ |
462 | private function getIpRangeConds( $db, $ip ) { |
463 | // First make sure it is a valid range and they are not outside the CIDR limit |
464 | if ( !self::isQueryableRange( $ip, $this->getConfig() ) ) { |
465 | return false; |
466 | } |
467 | |
468 | [ $start, $end ] = IPUtils::parseRange( $ip ); |
469 | |
470 | return 'ipc_hex BETWEEN ' . $db->addQuotes( $start ) . ' AND ' . $db->addQuotes( $end ); |
471 | } |
472 | |
473 | /** |
474 | * Is the given IP a range and within the CIDR limit? |
475 | * |
476 | * @internal Public only for SpecialContributions |
477 | * @param string $ipRange |
478 | * @param Config $config |
479 | * @return bool True if it is valid |
480 | * @since 1.30 |
481 | */ |
482 | public static function isQueryableRange( $ipRange, $config ) { |
483 | $limits = $config->get( MainConfigNames::RangeContributionsCIDRLimit ); |
484 | |
485 | $bits = IPUtils::parseCIDR( $ipRange )[1]; |
486 | if ( |
487 | ( $bits === false ) || |
488 | ( IPUtils::isIPv4( $ipRange ) && $bits < $limits['IPv4'] ) || |
489 | ( IPUtils::isIPv6( $ipRange ) && $bits < $limits['IPv6'] ) |
490 | ) { |
491 | return false; |
492 | } |
493 | |
494 | return true; |
495 | } |
496 | |
497 | /** |
498 | * @return string |
499 | */ |
500 | public function getIndexField() { |
501 | // The returned column is used for sorting and continuation, so we need to |
502 | // make sure to use the right denormalized column depending on which table is |
503 | // being targeted by the query to avoid bad query plans. |
504 | // See T200259, T204669, T220991, and T221380. |
505 | $target = $this->getTargetTable(); |
506 | switch ( $target ) { |
507 | case 'revision': |
508 | return 'rev_timestamp'; |
509 | case 'ip_changes': |
510 | return 'ipc_rev_timestamp'; |
511 | default: |
512 | wfWarn( |
513 | __METHOD__ . ": Unknown value '$target' from " . static::class . '::getTargetTable()', 0 |
514 | ); |
515 | return 'rev_timestamp'; |
516 | } |
517 | } |
518 | |
519 | /** |
520 | * @return false|string[] |
521 | */ |
522 | public function getTagFilter() { |
523 | return $this->tagFilter; |
524 | } |
525 | |
526 | /** |
527 | * @return string |
528 | */ |
529 | public function getTarget() { |
530 | return $this->target; |
531 | } |
532 | |
533 | /** |
534 | * @return bool |
535 | */ |
536 | public function isNewOnly() { |
537 | return $this->newOnly; |
538 | } |
539 | |
540 | /** |
541 | * @return int|string |
542 | */ |
543 | public function getNamespace() { |
544 | return $this->namespace; |
545 | } |
546 | |
547 | /** |
548 | * @return string[] |
549 | */ |
550 | protected function getExtraSortFields() { |
551 | // The returned columns are used for sorting, so we need to make sure |
552 | // to use the right denormalized column depending on which table is |
553 | // being targeted by the query to avoid bad query plans. |
554 | // See T200259, T204669, T220991, and T221380. |
555 | $target = $this->getTargetTable(); |
556 | switch ( $target ) { |
557 | case 'revision': |
558 | return [ 'rev_id' ]; |
559 | case 'ip_changes': |
560 | return [ 'ipc_rev_id' ]; |
561 | default: |
562 | wfWarn( |
563 | __METHOD__ . ": Unknown value '$target' from " . static::class . '::getTargetTable()', 0 |
564 | ); |
565 | return [ 'rev_id' ]; |
566 | } |
567 | } |
568 | |
569 | protected function doBatchLookups() { |
570 | # Do a link batch query |
571 | $this->mResult->seek( 0 ); |
572 | $parentRevIds = []; |
573 | $this->mParentLens = []; |
574 | $revisions = []; |
575 | $linkBatch = $this->linkBatchFactory->newLinkBatch(); |
576 | $isIpRange = self::isQueryableRange( $this->target, $this->getConfig() ); |
577 | # Give some pointers to make (last) links |
578 | foreach ( $this->mResult as $row ) { |
579 | if ( isset( $row->rev_parent_id ) && $row->rev_parent_id ) { |
580 | $parentRevIds[] = (int)$row->rev_parent_id; |
581 | } |
582 | if ( $this->revisionStore->isRevisionRow( $row ) ) { |
583 | $this->mParentLens[(int)$row->rev_id] = $row->rev_len; |
584 | if ( $isIpRange ) { |
585 | // If this is an IP range, batch the IP's talk page |
586 | $linkBatch->add( NS_USER_TALK, $row->rev_user_text ); |
587 | } |
588 | $linkBatch->add( $row->page_namespace, $row->page_title ); |
589 | $revisions[$row->rev_id] = $this->revisionStore->newRevisionFromRow( $row ); |
590 | } |
591 | } |
592 | # Fetch rev_len for revisions not already scanned above |
593 | $this->mParentLens += $this->revisionStore->getRevisionSizes( |
594 | array_diff( $parentRevIds, array_keys( $this->mParentLens ) ) |
595 | ); |
596 | $linkBatch->execute(); |
597 | |
598 | $this->formattedComments = $this->commentFormatter->createRevisionBatch() |
599 | ->authority( $this->getAuthority() ) |
600 | ->revisions( $revisions ) |
601 | ->hideIfDeleted() |
602 | ->execute(); |
603 | |
604 | # For performance, save the revision objects for later. |
605 | # The array is indexed by rev_id. doBatchLookups() may be called |
606 | # multiple times with different results, so merge the revisions array, |
607 | # ignoring any duplicates. |
608 | $this->revisions += $revisions; |
609 | } |
610 | |
611 | /** |
612 | * @inheritDoc |
613 | */ |
614 | protected function getStartBody() { |
615 | return "<section class='mw-pager-body'>\n"; |
616 | } |
617 | |
618 | /** |
619 | * @inheritDoc |
620 | */ |
621 | protected function getEndBody() { |
622 | return "</section>\n"; |
623 | } |
624 | |
625 | /** |
626 | * If the object looks like a revision row, or corresponds to a previously |
627 | * cached revision, return the RevisionRecord. Otherwise, return null. |
628 | * |
629 | * @since 1.35 |
630 | * |
631 | * @param mixed $row |
632 | * @param Title|null $title |
633 | * @return RevisionRecord|null |
634 | */ |
635 | public function tryCreatingRevisionRecord( $row, $title = null ) { |
636 | if ( $row instanceof stdClass && isset( $row->rev_id ) |
637 | && isset( $this->revisions[$row->rev_id] ) |
638 | ) { |
639 | return $this->revisions[$row->rev_id]; |
640 | } elseif ( $this->revisionStore->isRevisionRow( $row ) ) { |
641 | return $this->revisionStore->newRevisionFromRow( $row, 0, $title ); |
642 | } else { |
643 | return null; |
644 | } |
645 | } |
646 | |
647 | /** |
648 | * Generates each row in the contributions list. |
649 | * |
650 | * Contributions which are marked "top" are currently on top of the history. |
651 | * For these contributions, a [rollback] link is shown for users with roll- |
652 | * back privileges. The rollback link restores the most recent version that |
653 | * was not written by the target user. |
654 | * |
655 | * @todo This would probably look a lot nicer in a table. |
656 | * @param stdClass|mixed $row |
657 | * @return string |
658 | */ |
659 | public function formatRow( $row ) { |
660 | $ret = ''; |
661 | $classes = []; |
662 | $attribs = []; |
663 | |
664 | $linkRenderer = $this->getLinkRenderer(); |
665 | |
666 | $page = null; |
667 | // Create a title for the revision if possible |
668 | // Rows from the hook may not include title information |
669 | if ( isset( $row->page_namespace ) && isset( $row->page_title ) ) { |
670 | $page = Title::newFromRow( $row ); |
671 | } |
672 | // Flow overrides the ContribsPager::reallyDoQuery hook, causing this |
673 | // function to be called with a special object for $row. It expects us |
674 | // skip formatting so that the row can be formatted by the |
675 | // ContributionsLineEnding hook below. |
676 | // FIXME: have some better way for extensions to provide formatted rows. |
677 | $revRecord = $this->tryCreatingRevisionRecord( $row, $page ); |
678 | if ( $revRecord && $page ) { |
679 | $revRecord = $this->revisionStore->newRevisionFromRow( $row, 0, $page ); |
680 | $attribs['data-mw-revid'] = $revRecord->getId(); |
681 | |
682 | $link = $linkRenderer->makeLink( |
683 | $page, |
684 | $page->getPrefixedText(), |
685 | [ 'class' => 'mw-contributions-title' ], |
686 | $page->isRedirect() ? [ 'redirect' => 'no' ] : [] |
687 | ); |
688 | # Mark current revisions |
689 | $topmarktext = ''; |
690 | |
691 | $pagerTools = new PagerTools( |
692 | $revRecord, |
693 | null, |
694 | $row->rev_id === $row->page_latest && !$row->page_is_new, |
695 | $this->hookRunner, |
696 | $page, |
697 | $this->getContext(), |
698 | $this->getLinkRenderer() |
699 | ); |
700 | if ( $row->rev_id === $row->page_latest ) { |
701 | $topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>'; |
702 | $classes[] = 'mw-contributions-current'; |
703 | } |
704 | if ( $pagerTools->shouldPreventClickjacking() ) { |
705 | $this->setPreventClickjacking( true ); |
706 | } |
707 | $topmarktext .= $pagerTools->toHTML(); |
708 | # Is there a visible previous revision? |
709 | if ( $revRecord->getParentId() !== 0 && |
710 | $revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) |
711 | ) { |
712 | $difftext = $linkRenderer->makeKnownLink( |
713 | $page, |
714 | new HtmlArmor( $this->messages['diff'] ), |
715 | [ 'class' => 'mw-changeslist-diff' ], |
716 | [ |
717 | 'diff' => 'prev', |
718 | 'oldid' => $row->rev_id |
719 | ] |
720 | ); |
721 | } else { |
722 | $difftext = $this->messages['diff']; |
723 | } |
724 | $histlink = $linkRenderer->makeKnownLink( |
725 | $page, |
726 | new HtmlArmor( $this->messages['hist'] ), |
727 | [ 'class' => 'mw-changeslist-history' ], |
728 | [ 'action' => 'history' ] |
729 | ); |
730 | |
731 | if ( $row->rev_parent_id === null ) { |
732 | // For some reason rev_parent_id isn't populated for this row. |
733 | // Its rumoured this is true on wikipedia for some revisions (T36922). |
734 | // Next best thing is to have the total number of bytes. |
735 | $chardiff = ' <span class="mw-changeslist-separator"></span> '; |
736 | $chardiff .= Linker::formatRevisionSize( $row->rev_len ); |
737 | $chardiff .= ' <span class="mw-changeslist-separator"></span> '; |
738 | } else { |
739 | $parentLen = 0; |
740 | if ( isset( $this->mParentLens[$row->rev_parent_id] ) ) { |
741 | $parentLen = $this->mParentLens[$row->rev_parent_id]; |
742 | } |
743 | |
744 | $chardiff = ' <span class="mw-changeslist-separator"></span> '; |
745 | $chardiff .= ChangesList::showCharacterDifference( |
746 | $parentLen, |
747 | $row->rev_len, |
748 | $this->getContext() |
749 | ); |
750 | $chardiff .= ' <span class="mw-changeslist-separator"></span> '; |
751 | } |
752 | |
753 | $lang = $this->getLanguage(); |
754 | |
755 | $comment = $this->formattedComments[$row->rev_id]; |
756 | |
757 | if ( $comment === '' ) { |
758 | $defaultComment = $this->messages['changeslist-nocomment']; |
759 | $comment = "<span class=\"comment mw-comment-none\">$defaultComment</span>"; |
760 | } |
761 | |
762 | $comment = $lang->getDirMark() . $comment; |
763 | |
764 | $authority = $this->getAuthority(); |
765 | $d = ChangesList::revDateLink( $revRecord, $authority, $lang, $page ); |
766 | |
767 | # When querying for an IP range, we want to always show user and user talk links. |
768 | $userlink = ''; |
769 | $revUser = $revRecord->getUser(); |
770 | $revUserId = $revUser ? $revUser->getId() : 0; |
771 | $revUserText = $revUser ? $revUser->getName() : ''; |
772 | if ( self::isQueryableRange( $this->target, $this->getConfig() ) ) { |
773 | $userlink = ' <span class="mw-changeslist-separator"></span> ' |
774 | . $lang->getDirMark() |
775 | . Linker::userLink( $revUserId, $revUserText ); |
776 | $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams( |
777 | Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() . ' '; |
778 | } |
779 | |
780 | $flags = []; |
781 | if ( $revRecord->getParentId() === 0 ) { |
782 | $flags[] = ChangesList::flag( 'newpage' ); |
783 | } |
784 | |
785 | if ( $revRecord->isMinor() ) { |
786 | $flags[] = ChangesList::flag( 'minor' ); |
787 | } |
788 | |
789 | $del = Linker::getRevDeleteLink( $authority, $revRecord, $page ); |
790 | if ( $del !== '' ) { |
791 | $del .= ' '; |
792 | } |
793 | |
794 | // While it might be tempting to use a list here |
795 | // this would result in clutter and slows down navigating the content |
796 | // in assistive technology. |
797 | // See https://phabricator.wikimedia.org/T205581#4734812 |
798 | $diffHistLinks = Html::rawElement( 'span', |
799 | [ 'class' => 'mw-changeslist-links' ], |
800 | // The spans are needed to ensure the dividing '|' elements are not |
801 | // themselves styled as links. |
802 | Html::rawElement( 'span', [], $difftext ) . |
803 | ' ' . // Space needed for separating two words. |
804 | Html::rawElement( 'span', [], $histlink ) |
805 | ); |
806 | |
807 | # Tags, if any. Save some time using a cache. |
808 | [ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback( |
809 | $this->tagsCache->makeKey( |
810 | $row->ts_tags ?? '', |
811 | $this->getUser()->getName(), |
812 | $lang->getCode() |
813 | ), |
814 | fn () => ChangeTags::formatSummaryRow( |
815 | $row->ts_tags, |
816 | null, |
817 | $this->getContext() |
818 | ) |
819 | ); |
820 | $classes = array_merge( $classes, $newClasses ); |
821 | |
822 | $this->hookRunner->onSpecialContributions__formatRow__flags( |
823 | $this->getContext(), $row, $flags ); |
824 | |
825 | $templateParams = [ |
826 | 'del' => $del, |
827 | 'timestamp' => $d, |
828 | 'diffHistLinks' => $diffHistLinks, |
829 | 'charDifference' => $chardiff, |
830 | 'flags' => $flags, |
831 | 'articleLink' => $link, |
832 | 'userlink' => $userlink, |
833 | 'logText' => $comment, |
834 | 'topmarktext' => $topmarktext, |
835 | 'tagSummary' => $tagSummary, |
836 | ]; |
837 | |
838 | # Denote if username is redacted for this edit |
839 | if ( $revRecord->isDeleted( RevisionRecord::DELETED_USER ) ) { |
840 | $templateParams['rev-deleted-user-contribs'] = |
841 | $this->msg( 'rev-deleted-user-contribs' )->escaped(); |
842 | } |
843 | |
844 | $ret = $this->templateParser->processTemplate( |
845 | 'SpecialContributionsLine', |
846 | $templateParams |
847 | ); |
848 | } |
849 | |
850 | // Let extensions add data |
851 | $this->hookRunner->onContributionsLineEnding( $this, $ret, $row, $classes, $attribs ); |
852 | $attribs = array_filter( $attribs, |
853 | [ Sanitizer::class, 'isReservedDataAttribute' ], |
854 | ARRAY_FILTER_USE_KEY |
855 | ); |
856 | |
857 | // TODO: Handle exceptions in the catch block above. Do any extensions rely on |
858 | // receiving empty rows? |
859 | |
860 | if ( $classes === [] && $attribs === [] && $ret === '' ) { |
861 | wfDebug( "Dropping Special:Contribution row that could not be formatted" ); |
862 | return "<!-- Could not format Special:Contribution row. -->\n"; |
863 | } |
864 | $attribs['class'] = $classes; |
865 | |
866 | // FIXME: The signature of the ContributionsLineEnding hook makes it |
867 | // very awkward to move this LI wrapper into the template. |
868 | return Html::rawElement( 'li', $attribs, $ret ) . "\n"; |
869 | } |
870 | |
871 | /** |
872 | * Overwrite Pager function and return a helpful comment |
873 | * @return string |
874 | */ |
875 | protected function getSqlComment() { |
876 | if ( $this->namespace || $this->deletedOnly ) { |
877 | // potentially slow, see CR r58153 |
878 | return 'contributions page filtered for namespace or RevisionDeleted edits'; |
879 | } else { |
880 | return 'contributions page unfiltered'; |
881 | } |
882 | } |
883 | |
884 | /** |
885 | * @deprecated since 1.38, use ::setPreventClickjacking() instead |
886 | */ |
887 | protected function preventClickjacking() { |
888 | $this->setPreventClickjacking( true ); |
889 | } |
890 | |
891 | /** |
892 | * @param bool $enable |
893 | * @since 1.38 |
894 | */ |
895 | protected function setPreventClickjacking( bool $enable ) { |
896 | $this->preventClickjacking = $enable; |
897 | } |
898 | |
899 | /** |
900 | * @return bool |
901 | */ |
902 | public function getPreventClickjacking() { |
903 | return $this->preventClickjacking; |
904 | } |
905 | |
906 | /** |
907 | * Set up date filter options, given request data. |
908 | * |
909 | * @param array $opts Options array |
910 | * @return array Options array with processed start and end date filter options |
911 | */ |
912 | public static function processDateFilter( array $opts ) { |
913 | $start = $opts['start'] ?? ''; |
914 | $end = $opts['end'] ?? ''; |
915 | $year = $opts['year'] ?? ''; |
916 | $month = $opts['month'] ?? ''; |
917 | |
918 | if ( $start !== '' && $end !== '' && $start > $end ) { |
919 | $temp = $start; |
920 | $start = $end; |
921 | $end = $temp; |
922 | } |
923 | |
924 | // If year/month legacy filtering options are set, convert them to display the new stamp |
925 | if ( $year !== '' || $month !== '' ) { |
926 | // Reuse getDateCond logic, but subtract a day because |
927 | // the endpoints of our date range appear inclusive |
928 | // but the internal end offsets are always exclusive |
929 | $legacyTimestamp = ReverseChronologicalPager::getOffsetDate( $year, $month ); |
930 | $legacyDateTime = new DateTime( $legacyTimestamp->getTimestamp( TS_ISO_8601 ) ); |
931 | $legacyDateTime = $legacyDateTime->modify( '-1 day' ); |
932 | |
933 | // Clear the new timestamp range options if used and |
934 | // replace with the converted legacy timestamp |
935 | $start = ''; |
936 | $end = $legacyDateTime->format( 'Y-m-d' ); |
937 | } |
938 | |
939 | $opts['start'] = $start; |
940 | $opts['end'] = $end; |
941 | |
942 | return $opts; |
943 | } |
944 | } |
945 | |
946 | /** |
947 | * Retain the old class name for backwards compatibility. |
948 | * @deprecated since 1.41 |
949 | */ |
950 | class_alias( ContribsPager::class, 'ContribsPager' ); |