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