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