Merge "StringUtils: Add a utility for checking if a string is a valid regex"
[lhc/web/wiklou.git] / includes / specials / pagers / DeletedContribsPager.php
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 /**
23 * @ingroup Pager
24 */
25 use MediaWiki\Linker\LinkRenderer;
26 use MediaWiki\MediaWikiServices;
27 use MediaWiki\Revision\RevisionRecord;
28 use Wikimedia\Rdbms\IDatabase;
29 use Wikimedia\Rdbms\IResultWrapper;
30 use Wikimedia\Rdbms\FakeResultWrapper;
31
32 class DeletedContribsPager extends IndexPager {
33
34 /**
35 * @var bool Default direction for pager
36 */
37 public $mDefaultDirection = IndexPager::DIR_DESCENDING;
38
39 /**
40 * @var string[] Local cache for escaped messages
41 */
42 public $messages;
43
44 /**
45 * @var string User name, or a string describing an IP address range
46 */
47 public $target;
48
49 /**
50 * @var string|int A single namespace number, or an empty string for all namespaces
51 */
52 public $namespace = '';
53
54 /**
55 * @var IDatabase
56 */
57 public $mDb;
58
59 /**
60 * @var string Navigation bar with paging links.
61 */
62 protected $mNavigationBar;
63
64 public function __construct( IContextSource $context, $target, $namespace = false,
65 LinkRenderer $linkRenderer
66 ) {
67 parent::__construct( $context, $linkRenderer );
68 $msgs = [ 'deletionlog', 'undeleteviewlink', 'diff' ];
69 foreach ( $msgs as $msg ) {
70 $this->messages[$msg] = $this->msg( $msg )->text();
71 }
72 $this->target = $target;
73 $this->namespace = $namespace;
74 $this->mDb = wfGetDB( DB_REPLICA, 'contributions' );
75 }
76
77 function getDefaultQuery() {
78 $query = parent::getDefaultQuery();
79 $query['target'] = $this->target;
80
81 return $query;
82 }
83
84 function getQueryInfo() {
85 $userCond = [
86 // ->getJoin() below takes care of any joins needed
87 ActorMigration::newMigration()->getWhere(
88 wfGetDB( DB_REPLICA ), 'ar_user', User::newFromName( $this->target, false ), false
89 )['conds']
90 ];
91 $conds = array_merge( $userCond, $this->getNamespaceCond() );
92 $user = $this->getUser();
93 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
94 // Paranoia: avoid brute force searches (T19792)
95 if ( !$permissionManager->userHasRight( $user, 'deletedhistory' ) ) {
96 $conds[] = $this->mDb->bitAnd( 'ar_deleted', RevisionRecord::DELETED_USER ) . ' = 0';
97 } elseif ( !$permissionManager->userHasAnyRight( $user, 'suppressrevision', 'viewsuppressed' ) ) {
98 $conds[] = $this->mDb->bitAnd( 'ar_deleted', RevisionRecord::SUPPRESSED_USER ) .
99 ' != ' . RevisionRecord::SUPPRESSED_USER;
100 }
101
102 $commentQuery = CommentStore::getStore()->getJoin( 'ar_comment' );
103 $actorQuery = ActorMigration::newMigration()->getJoin( 'ar_user' );
104
105 return [
106 'tables' => [ 'archive' ] + $commentQuery['tables'] + $actorQuery['tables'],
107 'fields' => [
108 'ar_rev_id', 'ar_namespace', 'ar_title', 'ar_timestamp',
109 'ar_minor_edit', 'ar_deleted'
110 ] + $commentQuery['fields'] + $actorQuery['fields'],
111 'conds' => $conds,
112 'options' => [],
113 'join_conds' => $commentQuery['joins'] + $actorQuery['joins'],
114 ];
115 }
116
117 /**
118 * This method basically executes the exact same code as the parent class, though with
119 * a hook added, to allow extensions to add additional queries.
120 *
121 * @param string $offset Index offset, inclusive
122 * @param int $limit Exact query limit
123 * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING
124 * @return IResultWrapper
125 */
126 function reallyDoQuery( $offset, $limit, $order ) {
127 $data = [ parent::reallyDoQuery( $offset, $limit, $order ) ];
128
129 // This hook will allow extensions to add in additional queries, nearly
130 // identical to ContribsPager::reallyDoQuery.
131 Hooks::run(
132 'DeletedContribsPager::reallyDoQuery',
133 [ &$data, $this, $offset, $limit, $order ]
134 );
135
136 $result = [];
137
138 // loop all results and collect them in an array
139 foreach ( $data as $query ) {
140 foreach ( $query as $i => $row ) {
141 // use index column as key, allowing us to easily sort in PHP
142 $result[$row->{$this->getIndexField()} . "-$i"] = $row;
143 }
144 }
145
146 // sort results
147 if ( $order === self::QUERY_ASCENDING ) {
148 ksort( $result );
149 } else {
150 krsort( $result );
151 }
152
153 // enforce limit
154 $result = array_slice( $result, 0, $limit );
155
156 // get rid of array keys
157 $result = array_values( $result );
158
159 return new FakeResultWrapper( $result );
160 }
161
162 function getIndexField() {
163 return 'ar_timestamp';
164 }
165
166 /**
167 * @return string
168 */
169 public function getTarget() {
170 return $this->target;
171 }
172
173 /**
174 * @return int|string
175 */
176 public function getNamespace() {
177 return $this->namespace;
178 }
179
180 protected function getStartBody() {
181 return "<ul>\n";
182 }
183
184 protected function getEndBody() {
185 return "</ul>\n";
186 }
187
188 function getNavigationBar() {
189 if ( isset( $this->mNavigationBar ) ) {
190 return $this->mNavigationBar;
191 }
192
193 $linkTexts = [
194 'prev' => $this->msg( 'pager-newer-n' )->numParams( $this->mLimit )->escaped(),
195 'next' => $this->msg( 'pager-older-n' )->numParams( $this->mLimit )->escaped(),
196 'first' => $this->msg( 'histlast' )->escaped(),
197 'last' => $this->msg( 'histfirst' )->escaped()
198 ];
199
200 $pagingLinks = $this->getPagingLinks( $linkTexts );
201 $limitLinks = $this->getLimitLinks();
202 $lang = $this->getLanguage();
203 $limits = $lang->pipeList( $limitLinks );
204
205 $firstLast = $lang->pipeList( [ $pagingLinks['first'], $pagingLinks['last'] ] );
206 $firstLast = $this->msg( 'parentheses' )->rawParams( $firstLast )->escaped();
207 $prevNext = $this->msg( 'viewprevnext' )
208 ->rawParams(
209 $pagingLinks['prev'],
210 $pagingLinks['next'],
211 $limits
212 )->escaped();
213 $separator = $this->msg( 'word-separator' )->escaped();
214 $this->mNavigationBar = $firstLast . $separator . $prevNext;
215
216 return $this->mNavigationBar;
217 }
218
219 function getNamespaceCond() {
220 if ( $this->namespace !== '' ) {
221 return [ 'ar_namespace' => (int)$this->namespace ];
222 } else {
223 return [];
224 }
225 }
226
227 /**
228 * Generates each row in the contributions list.
229 *
230 * @todo This would probably look a lot nicer in a table.
231 * @param stdClass $row
232 * @return string
233 */
234 function formatRow( $row ) {
235 $ret = '';
236 $classes = [];
237 $attribs = [];
238
239 /*
240 * There may be more than just revision rows. To make sure that we'll only be processing
241 * revisions here, let's _try_ to build a revision out of our row (without displaying
242 * notices though) and then trying to grab data from the built object. If we succeed,
243 * we're definitely dealing with revision data and we may proceed, if not, we'll leave it
244 * to extensions to subscribe to the hook to parse the row.
245 */
246 Wikimedia\suppressWarnings();
247 try {
248 $rev = Revision::newFromArchiveRow( $row );
249 $validRevision = (bool)$rev->getId();
250 } catch ( Exception $e ) {
251 $validRevision = false;
252 }
253 Wikimedia\restoreWarnings();
254
255 if ( $validRevision ) {
256 $attribs['data-mw-revid'] = $rev->getId();
257 $ret = $this->formatRevisionRow( $row );
258 }
259
260 // Let extensions add data
261 Hooks::run( 'DeletedContributionsLineEnding', [ $this, &$ret, $row, &$classes, &$attribs ] );
262 $attribs = array_filter( $attribs,
263 [ Sanitizer::class, 'isReservedDataAttribute' ],
264 ARRAY_FILTER_USE_KEY
265 );
266
267 if ( $classes === [] && $attribs === [] && $ret === '' ) {
268 wfDebug( "Dropping Special:DeletedContribution row that could not be formatted\n" );
269 $ret = "<!-- Could not format Special:DeletedContribution row. -->\n";
270 } else {
271 $attribs['class'] = $classes;
272 $ret = Html::rawElement( 'li', $attribs, $ret ) . "\n";
273 }
274
275 return $ret;
276 }
277
278 /**
279 * Generates each row in the contributions list for archive entries.
280 *
281 * Contributions which are marked "top" are currently on top of the history.
282 * For these contributions, a [rollback] link is shown for users with sysop
283 * privileges. The rollback link restores the most recent version that was not
284 * written by the target user.
285 *
286 * @todo This would probably look a lot nicer in a table.
287 * @param stdClass $row
288 * @return string
289 */
290 function formatRevisionRow( $row ) {
291 $page = Title::makeTitle( $row->ar_namespace, $row->ar_title );
292
293 $linkRenderer = $this->getLinkRenderer();
294
295 $rev = new Revision( [
296 'title' => $page,
297 'id' => $row->ar_rev_id,
298 'comment' => CommentStore::getStore()->getComment( 'ar_comment', $row )->text,
299 'user' => $row->ar_user,
300 'user_text' => $row->ar_user_text,
301 'actor' => $row->ar_actor ?? null,
302 'timestamp' => $row->ar_timestamp,
303 'minor_edit' => $row->ar_minor_edit,
304 'deleted' => $row->ar_deleted,
305 ] );
306
307 $undelete = SpecialPage::getTitleFor( 'Undelete' );
308
309 $logs = SpecialPage::getTitleFor( 'Log' );
310 $dellog = $linkRenderer->makeKnownLink(
311 $logs,
312 $this->messages['deletionlog'],
313 [],
314 [
315 'type' => 'delete',
316 'page' => $page->getPrefixedText()
317 ]
318 );
319
320 $reviewlink = $linkRenderer->makeKnownLink(
321 SpecialPage::getTitleFor( 'Undelete', $page->getPrefixedDBkey() ),
322 $this->messages['undeleteviewlink']
323 );
324
325 $user = $this->getUser();
326 $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
327
328 if ( $permissionManager->userHasRight( $user, 'deletedtext' ) ) {
329 $last = $linkRenderer->makeKnownLink(
330 $undelete,
331 $this->messages['diff'],
332 [],
333 [
334 'target' => $page->getPrefixedText(),
335 'timestamp' => $rev->getTimestamp(),
336 'diff' => 'prev'
337 ]
338 );
339 } else {
340 $last = htmlspecialchars( $this->messages['diff'] );
341 }
342
343 $comment = Linker::revComment( $rev );
344 $date = $this->getLanguage()->userTimeAndDate( $rev->getTimestamp(), $user );
345
346 if ( !$permissionManager->userHasRight( $user, 'undelete' ) ||
347 !$rev->userCan( RevisionRecord::DELETED_TEXT, $user )
348 ) {
349 $link = htmlspecialchars( $date ); // unusable link
350 } else {
351 $link = $linkRenderer->makeKnownLink(
352 $undelete,
353 $date,
354 [ 'class' => 'mw-changeslist-date' ],
355 [
356 'target' => $page->getPrefixedText(),
357 'timestamp' => $rev->getTimestamp()
358 ]
359 );
360 }
361 // Style deleted items
362 if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
363 $link = '<span class="history-deleted">' . $link . '</span>';
364 }
365
366 $pagelink = $linkRenderer->makeLink(
367 $page,
368 null,
369 [ 'class' => 'mw-changeslist-title' ]
370 );
371
372 if ( $rev->isMinor() ) {
373 $mflag = ChangesList::flag( 'minor' );
374 } else {
375 $mflag = '';
376 }
377
378 // Revision delete link
379 $del = Linker::getRevDeleteLink( $user, $rev, $page );
380 if ( $del ) {
381 $del .= ' ';
382 }
383
384 $tools = Html::rawElement(
385 'span',
386 [ 'class' => 'mw-deletedcontribs-tools' ],
387 $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList(
388 [ $last, $dellog, $reviewlink ] ) )->escaped()
389 );
390
391 $separator = '<span class="mw-changeslist-separator">. .</span>';
392 $ret = "{$del}{$link} {$tools} {$separator} {$mflag} {$pagelink} {$comment}";
393
394 # Denote if username is redacted for this edit
395 if ( $rev->isDeleted( RevisionRecord::DELETED_USER ) ) {
396 $ret .= " <strong>" . $this->msg( 'rev-deleted-user-contribs' )->escaped() . "</strong>";
397 }
398
399 return $ret;
400 }
401
402 /**
403 * Get the Database object in use
404 *
405 * @return IDatabase
406 */
407 public function getDatabase() {
408 return $this->mDb;
409 }
410 }