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 Wikimedia\Rdbms\FakeResultWrapper;
28use Wikimedia\Rdbms\IResultWrapper;
29
30class ImageListPager extends TablePager {
31
32	protected $mFieldNames = null;
33
34	// Subclasses should override buildQueryConds instead of using $mQueryConds variable.
35	protected $mQueryConds = [];
36
37	protected $mUserName = null;
38
39	/**
40	 * The relevant user
41	 *
42	 * @var User|null
43	 */
44	protected $mUser = null;
45
46	protected $mSearch = '';
47
48	protected $mIncluding = false;
49
50	protected $mShowAll = false;
51
52	protected $mTableName = 'image';
53
54	/**
55	 * The unique sort fields for the sort options for unique pagniate
56	 */
57	private const INDEX_FIELDS = [
58		'img_timestamp' => [ 'img_timestamp', 'img_name' ],
59		'img_name' => [ 'img_name' ],
60		'img_size' => [ 'img_size', 'img_name' ],
61	];
62
63	public function __construct( IContextSource $context, $userName, $search,
64		$including, $showAll, LinkRenderer $linkRenderer
65	) {
66		$this->setContext( $context );
67
68		$this->mIncluding = $including;
69		$this->mShowAll = $showAll;
70
71		if ( $userName !== null && $userName !== '' ) {
72			$nt = Title::makeTitleSafe( NS_USER, $userName );
73			if ( $nt === null ) {
74				$this->outputUserDoesNotExist( $userName );
75			} else {
76				$this->mUserName = $nt->getText();
77				$user = User::newFromName( $this->mUserName, false );
78				if ( $user ) {
79					$this->mUser = $user;
80				}
81				if ( !$user || ( $user->isAnon() && !User::isIP( $user->getName() ) ) ) {
82					$this->outputUserDoesNotExist( $userName );
83				}
84			}
85		}
86
87		if ( $search !== '' && !$this->getConfig()->get( 'MiserMode' ) ) {
88			$this->mSearch = $search;
89			$nt = Title::newFromText( $this->mSearch );
90
91			if ( $nt ) {
92				$dbr = wfGetDB( DB_REPLICA );
93				$this->mQueryConds[] = 'LOWER(img_name)' .
94					$dbr->buildLike( $dbr->anyString(),
95						strtolower( $nt->getDBkey() ), $dbr->anyString() );
96			}
97		}
98
99		if ( !$including ) {
100			if ( $this->getRequest()->getText( 'sort', 'img_date' ) == 'img_date' ) {
101				$this->mDefaultDirection = IndexPager::DIR_DESCENDING;
102			} else {
103				$this->mDefaultDirection = IndexPager::DIR_ASCENDING;
104			}
105		} else {
106			$this->mDefaultDirection = IndexPager::DIR_DESCENDING;
107		}
108
109		parent::__construct( $context, $linkRenderer );
110	}
111
112	/**
113	 * Get the user relevant to the ImageList
114	 *
115	 * @return User|null
116	 */
117	public function getRelevantUser() {
118		return $this->mUser;
119	}
120
121	/**
122	 * Add a message to the output stating that the user doesn't exist
123	 *
124	 * @param string $userName Unescaped user name
125	 */
126	protected function outputUserDoesNotExist( $userName ) {
127		$this->getOutput()->wrapWikiMsg(
128			"<div class=\"mw-userpage-userdoesnotexist error\">\n$1\n</div>",
129			[
130				'listfiles-userdoesnotexist',
131				wfEscapeWikiText( $userName ),
132			]
133		);
134	}
135
136	/**
137	 * Build the where clause of the query.
138	 *
139	 * Replaces the older mQueryConds member variable.
140	 * @param string $table Either "image" or "oldimage"
141	 * @return array The query conditions.
142	 */
143	protected function buildQueryConds( $table ) {
144		$prefix = $table === 'image' ? 'img' : 'oi';
145		$conds = [];
146
147		if ( $this->mUserName !== null ) {
148			// getQueryInfoReal() should have handled the tables and joins.
149			$dbr = wfGetDB( DB_REPLICA );
150			$actorWhere = ActorMigration::newMigration()->getWhere(
151				$dbr,
152				$prefix . '_user',
153				User::newFromName( $this->mUserName, false ),
154				// oldimage doesn't have an index on oi_user, while image does. Set $useId accordingly.
155				$prefix === 'img'
156			);
157			$conds[] = $actorWhere['conds'];
158		}
159
160		if ( $this->mSearch !== '' ) {
161			$nt = Title::newFromText( $this->mSearch );
162			if ( $nt ) {
163				$dbr = wfGetDB( DB_REPLICA );
164				$conds[] = 'LOWER(' . $prefix . '_name)' .
165					$dbr->buildLike( $dbr->anyString(),
166						strtolower( $nt->getDBkey() ), $dbr->anyString() );
167			}
168		}
169
170		if ( $table === 'oldimage' ) {
171			// Don't want to deal with revdel.
172			// Future fixme: Show partial information as appropriate.
173			// Would have to be careful about filtering by username when username is deleted.
174			$conds['oi_deleted'] = 0;
175		}
176
177		// Add mQueryConds in case anyone was subclassing and using the old variable.
178		return $conds + $this->mQueryConds;
179	}
180
181	/**
182	 * The array keys (but not the array values) are used in sql. Phan
183	 * gets confused by this, so mark this method as being ok for sql in general.
184	 * @return-taint onlysafefor_sql
185	 * @return array
186	 */
187	protected function getFieldNames() {
188		if ( !$this->mFieldNames ) {
189			$this->mFieldNames = [
190				'img_timestamp' => $this->msg( 'listfiles_date' )->text(),
191				'img_name' => $this->msg( 'listfiles_name' )->text(),
192				'thumb' => $this->msg( 'listfiles_thumb' )->text(),
193				'img_size' => $this->msg( 'listfiles_size' )->text(),
194			];
195			if ( $this->mUserName === null ) {
196				// Do not show username if filtering by username
197				$this->mFieldNames['img_user_text'] = $this->msg( 'listfiles_user' )->text();
198			}
199			// img_description down here, in order so that its still after the username field.
200			$this->mFieldNames['img_description'] = $this->msg( 'listfiles_description' )->text();
201
202			if ( !$this->getConfig()->get( 'MiserMode' ) && !$this->mShowAll ) {
203				$this->mFieldNames['count'] = $this->msg( 'listfiles_count' )->text();
204			}
205			if ( $this->mShowAll ) {
206				$this->mFieldNames['top'] = $this->msg( 'listfiles-latestversion' )->text();
207			}
208		}
209
210		return $this->mFieldNames;
211	}
212
213	protected function isFieldSortable( $field ) {
214		if ( $this->mIncluding ) {
215			return false;
216		}
217		$sortable = array_keys( self::INDEX_FIELDS );
218		/* For reference, the indicies we can use for sorting are:
219		 * On the image table: img_user_timestamp/img_usertext_timestamp/img_actor_timestamp,
220		 * img_size, img_timestamp
221		 * On oldimage: oi_usertext_timestamp/oi_actor_timestamp, oi_name_timestamp
222		 *
223		 * In particular that means we cannot sort by timestamp when not filtering
224		 * by user and including old images in the results. Which is sad.
225		 */
226		if ( $this->getConfig()->get( 'MiserMode' ) && $this->mUserName !== null ) {
227			// If we're sorting by user, the index only supports sorting by time.
228			if ( $field === 'img_timestamp' ) {
229				return true;
230			} else {
231				return false;
232			}
233		} elseif ( $this->getConfig()->get( 'MiserMode' )
234			&& $this->mShowAll /* && mUserName === null */
235		) {
236			// no oi_timestamp index, so only alphabetical sorting in this case.
237			if ( $field === 'img_name' ) {
238				return true;
239			} else {
240				return false;
241			}
242		}
243
244		return in_array( $field, $sortable );
245	}
246
247	public function getQueryInfo() {
248		// Hacky Hacky Hacky - I want to get query info
249		// for two different tables, without reimplementing
250		// the pager class.
251		$qi = $this->getQueryInfoReal( $this->mTableName );
252
253		return $qi;
254	}
255
256	/**
257	 * Actually get the query info.
258	 *
259	 * This is to allow displaying both stuff from image and oldimage table.
260	 *
261	 * This is a bit hacky.
262	 *
263	 * @param string $table Either 'image' or 'oldimage'
264	 * @return array Query info
265	 */
266	protected function getQueryInfoReal( $table ) {
267		$dbr = wfGetDB( DB_REPLICA );
268		$prefix = $table === 'oldimage' ? 'oi' : 'img';
269
270		$tables = [ $table ];
271		$fields = array_keys( $this->getFieldNames() );
272		$fields = array_combine( $fields, $fields );
273		unset( $fields['img_description'] );
274		unset( $fields['img_user_text'] );
275
276		if ( $table === 'oldimage' ) {
277			foreach ( $fields as $id => $field ) {
278				if ( substr( $id, 0, 4 ) === 'img_' ) {
279					$fields[$id] = $prefix . substr( $field, 3 );
280				}
281			}
282			$fields['top'] = $dbr->addQuotes( 'no' );
283		} elseif ( $this->mShowAll ) {
284			$fields['top'] = $dbr->addQuotes( 'yes' );
285		}
286		$fields['thumb'] = $prefix . '_name';
287
288		$options = $join_conds = [];
289
290		# Description field
291		$commentQuery = CommentStore::getStore()->getJoin( $prefix . '_description' );
292		$tables += $commentQuery['tables'];
293		$fields += $commentQuery['fields'];
294		$join_conds += $commentQuery['joins'];
295		$fields['description_field'] = $dbr->addQuotes( "{$prefix}_description" );
296
297		# User fields
298		$actorQuery = ActorMigration::newMigration()->getJoin( $prefix . '_user' );
299		$tables += $actorQuery['tables'];
300		$join_conds += $actorQuery['joins'];
301		$fields['img_user'] = $actorQuery['fields'][$prefix . '_user'];
302		$fields['img_user_text'] = $actorQuery['fields'][$prefix . '_user_text'];
303		$fields['img_actor'] = $actorQuery['fields'][$prefix . '_actor'];
304
305		# Depends on $wgMiserMode
306		# Will also not happen if mShowAll is true.
307		if ( isset( $fields['count'] ) ) {
308			$fields['count'] = $dbr->buildSelectSubquery(
309				'oldimage',
310				'COUNT(oi_archive_name)',
311				'oi_name = img_name',
312				__METHOD__
313			);
314		}
315
316		return [
317			'tables' => $tables,
318			'fields' => $fields,
319			'conds' => $this->buildQueryConds( $table ),
320			'options' => $options,
321			'join_conds' => $join_conds
322		];
323	}
324
325	/**
326	 * Override reallyDoQuery to mix together two queries.
327	 *
328	 * @note $asc is named $descending in IndexPager base class. However
329	 *   it is true when the order is ascending, and false when the order
330	 *   is descending, so I renamed it to $asc here.
331	 * @param string $offset
332	 * @param int $limit
333	 * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING
334	 * @return FakeResultWrapper
335	 * @throws MWException
336	 */
337	public function reallyDoQuery( $offset, $limit, $order ) {
338		$prevTableName = $this->mTableName;
339		$this->mTableName = 'image';
340		list( $tables, $fields, $conds, $fname, $options, $join_conds ) =
341			$this->buildQueryInfo( $offset, $limit, $order );
342		$imageRes = $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds );
343		$this->mTableName = $prevTableName;
344
345		if ( !$this->mShowAll ) {
346			return $imageRes;
347		}
348
349		$this->mTableName = 'oldimage';
350
351		# Hacky...
352		$oldIndex = $this->mIndexField;
353		foreach ( $this->mIndexField as &$index ) {
354			if ( substr( $index, 0, 4 ) !== 'img_' ) {
355				throw new MWException( "Expected to be sorting on an image table field" );
356			}
357			$index = 'oi_' . substr( $index, 4 );
358		}
359
360		list( $tables, $fields, $conds, $fname, $options, $join_conds ) =
361			$this->buildQueryInfo( $offset, $limit, $order );
362		$oldimageRes = $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds );
363
364		$this->mTableName = $prevTableName;
365		$this->mIndexField = $oldIndex;
366
367		return $this->combineResult( $imageRes, $oldimageRes, $limit, $order );
368	}
369
370	/**
371	 * Combine results from 2 tables.
372	 *
373	 * Note: This will throw away some results
374	 *
375	 * @param IResultWrapper $res1
376	 * @param IResultWrapper $res2
377	 * @param int $limit
378	 * @param bool $ascending See note about $asc in $this->reallyDoQuery
379	 * @return FakeResultWrapper $res1 and $res2 combined
380	 */
381	protected function combineResult( $res1, $res2, $limit, $ascending ) {
382		$res1->rewind();
383		$res2->rewind();
384		$topRes1 = $res1->next();
385		$topRes2 = $res2->next();
386		$resultArray = [];
387		for ( $i = 0; $i < $limit && $topRes1 && $topRes2; $i++ ) {
388			if ( strcmp( $topRes1->{$this->mIndexField[0]}, $topRes2->{$this->mIndexField[0]} ) > 0 ) {
389				if ( !$ascending ) {
390					$resultArray[] = $topRes1;
391					$topRes1 = $res1->next();
392				} else {
393					$resultArray[] = $topRes2;
394					$topRes2 = $res2->next();
395				}
396			} elseif ( !$ascending ) {
397				$resultArray[] = $topRes2;
398				$topRes2 = $res2->next();
399			} else {
400				$resultArray[] = $topRes1;
401				$topRes1 = $res1->next();
402			}
403		}
404
405		for ( ; $i < $limit && $topRes1; $i++ ) {
406			$resultArray[] = $topRes1;
407			$topRes1 = $res1->next();
408		}
409
410		for ( ; $i < $limit && $topRes2; $i++ ) {
411			$resultArray[] = $topRes2;
412			$topRes2 = $res2->next();
413		}
414
415		return new FakeResultWrapper( $resultArray );
416	}
417
418	public function getIndexField() {
419		return [ self::INDEX_FIELDS[$this->mSort] ];
420	}
421
422	public function getDefaultSort() {
423		if ( $this->mShowAll && $this->getConfig()->get( 'MiserMode' ) && $this->mUserName === null ) {
424			// Unfortunately no index on oi_timestamp.
425			return 'img_name';
426		} else {
427			return 'img_timestamp';
428		}
429	}
430
431	protected function doBatchLookups() {
432		$userIds = [];
433		$this->mResult->seek( 0 );
434		foreach ( $this->mResult as $row ) {
435			$userIds[] = $row->img_user;
436		}
437		# Do a link batch query for names and userpages
438		UserCache::singleton()->doQuery( $userIds, [ 'userpage' ], __METHOD__ );
439	}
440
441	/**
442	 * @param string $field
443	 * @param string $value
444	 * @return Message|string|int The return type depends on the value of $field:
445	 *   - thumb: string
446	 *   - img_timestamp: string
447	 *   - img_name: string
448	 *   - img_user_text: string
449	 *   - img_size: string
450	 *   - img_description: string
451	 *   - count: int
452	 *   - top: Message
453	 * @throws MWException
454	 */
455	public function formatValue( $field, $value ) {
456		$services = MediaWikiServices::getInstance();
457		$linkRenderer = $this->getLinkRenderer();
458		switch ( $field ) {
459			case 'thumb':
460				$opt = [ 'time' => wfTimestamp( TS_MW, $this->mCurrentRow->img_timestamp ) ];
461				$file = $services->getRepoGroup()->getLocalRepo()->findFile( $value, $opt );
462				// If statement for paranoia
463				if ( $file ) {
464					$thumb = $file->transform( [ 'width' => 180, 'height' => 360 ] );
465					if ( $thumb ) {
466						return $thumb->toHtml( [ 'desc-link' => true ] );
467					} else {
468						return $this->msg( 'thumbnail_error', '' )->escaped();
469					}
470				} else {
471					return htmlspecialchars( $value );
472				}
473			case 'img_timestamp':
474				// We may want to make this a link to the "old" version when displaying old files
475				return htmlspecialchars( $this->getLanguage()->userTimeAndDate( $value, $this->getUser() ) );
476			case 'img_name':
477				static $imgfile = null;
478				if ( $imgfile === null ) {
479					$imgfile = $this->msg( 'imgfile' )->text();
480				}
481
482				// Weird files can maybe exist? T24227
483				$filePage = Title::makeTitleSafe( NS_FILE, $value );
484				if ( $filePage ) {
485					$link = $linkRenderer->makeKnownLink(
486						$filePage,
487						$filePage->getText()
488					);
489					$download = Xml::element(
490						'a',
491						[ 'href' => $services->getRepoGroup()->getLocalRepo()->newFile( $filePage )->getUrl() ],
492						$imgfile
493					);
494					$download = $this->msg( 'parentheses' )->rawParams( $download )->escaped();
495
496					// Add delete links if allowed
497					// From https://github.com/Wikia/app/pull/3859
498					$permissionManager = $services->getPermissionManager();
499
500					if ( $permissionManager->userCan( 'delete', $this->getUser(), $filePage ) ) {
501						$deleteMsg = $this->msg( 'listfiles-delete' )->text();
502
503						$delete = $linkRenderer->makeKnownLink(
504							$filePage, $deleteMsg, [], [ 'action' => 'delete' ]
505						);
506						$delete = $this->msg( 'parentheses' )->rawParams( $delete )->escaped();
507
508						return "$link $download $delete";
509					}
510
511					return "$link $download";
512				} else {
513					return htmlspecialchars( $value );
514				}
515			case 'img_user_text':
516				if ( $this->mCurrentRow->img_user ) {
517					$name = User::whoIs( $this->mCurrentRow->img_user );
518					$link = $linkRenderer->makeLink(
519						Title::makeTitle( NS_USER, $name ),
520						$name
521					);
522				} else {
523					$link = htmlspecialchars( $value );
524				}
525
526				return $link;
527			case 'img_size':
528				return htmlspecialchars( $this->getLanguage()->formatSize( $value ) );
529			case 'img_description':
530				$field = $this->mCurrentRow->description_field;
531				$value = CommentStore::getStore()->getComment( $field, $this->mCurrentRow )->text;
532				return Linker::formatComment( $value );
533			case 'count':
534				return $this->getLanguage()->formatNum( intval( $value ) + 1 );
535			case 'top':
536				// Messages: listfiles-latestversion-yes, listfiles-latestversion-no
537				return $this->msg( 'listfiles-latestversion-' . $value )->escaped();
538			default:
539				throw new MWException( "Unknown field '$field'" );
540		}
541	}
542
543	public function getForm() {
544		$formDescriptor = [];
545		$formDescriptor['limit'] = [
546			'type' => 'select',
547			'name' => 'limit',
548			'label-message' => 'table_pager_limit_label',
549			'options' => $this->getLimitSelectList(),
550			'default' => $this->mLimit,
551		];
552
553		if ( !$this->getConfig()->get( 'MiserMode' ) ) {
554			$formDescriptor['ilsearch'] = [
555				'type' => 'text',
556				'name' => 'ilsearch',
557				'id' => 'mw-ilsearch',
558				'label-message' => 'listfiles_search_for',
559				'default' => $this->mSearch,
560				'size' => '40',
561				'maxlength' => '255',
562			];
563		}
564
565		$formDescriptor['user'] = [
566			'type' => 'user',
567			'name' => 'user',
568			'id' => 'mw-listfiles-user',
569			'label-message' => 'username',
570			'default' => $this->mUserName,
571			'size' => '40',
572			'maxlength' => '255',
573		];
574
575		$formDescriptor['ilshowall'] = [
576			'type' => 'check',
577			'name' => 'ilshowall',
578			'id' => 'mw-listfiles-show-all',
579			'label-message' => 'listfiles-show-all',
580			'default' => $this->mShowAll,
581		];
582
583		$query = $this->getRequest()->getQueryValues();
584		unset( $query['title'] );
585		unset( $query['limit'] );
586		unset( $query['ilsearch'] );
587		unset( $query['ilshowall'] );
588		unset( $query['user'] );
589
590		$htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
591		$htmlForm
592			->setMethod( 'get' )
593			->setId( 'mw-listfiles-form' )
594			->setTitle( $this->getTitle() )
595			->setSubmitTextMsg( 'table_pager_limit_submit' )
596			->setWrapperLegendMsg( 'listfiles' )
597			->addHiddenFields( $query )
598			->prepareForm()
599			->displayForm( '' );
600	}
601
602	protected function getTableClass() {
603		return parent::getTableClass() . ' listfiles';
604	}
605
606	protected function getNavClass() {
607		return parent::getNavClass() . ' listfiles_nav';
608	}
609
610	protected function getSortHeaderClass() {
611		return parent::getSortHeaderClass() . ' listfiles_sort';
612	}
613
614	public function getPagingQueries() {
615		$queries = parent::getPagingQueries();
616		if ( $this->mUserName !== null ) {
617			# Append the username to the query string
618			foreach ( $queries as &$query ) {
619				if ( $query !== false ) {
620					$query['user'] = $this->mUserName;
621				}
622			}
623		}
624
625		return $queries;
626	}
627
628	public function getDefaultQuery() {
629		$queries = parent::getDefaultQuery();
630		if ( !isset( $queries['user'] ) && $this->mUserName !== null ) {
631			$queries['user'] = $this->mUserName;
632		}
633
634		return $queries;
635	}
636
637	public function getTitle() {
638		return SpecialPage::getTitleFor( 'Listfiles' );
639	}
640}
641