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