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