Merge "FileRepo: Use Late Static Binding in File static constructors"
[lhc/web/wiklou.git] / includes / filerepo / file / LocalFile.php
1 <?php
2 /**
3 * Local file in the wiki's own database.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup FileAbstraction
22 */
23
24 use Wikimedia\AtEase\AtEase;
25 use MediaWiki\Logger\LoggerFactory;
26 use Wikimedia\Rdbms\Database;
27 use Wikimedia\Rdbms\IDatabase;
28 use MediaWiki\MediaWikiServices;
29
30 /**
31 * Class to represent a local file in the wiki's own database
32 *
33 * Provides methods to retrieve paths (physical, logical, URL),
34 * to generate image thumbnails or for uploading.
35 *
36 * Note that only the repo object knows what its file class is called. You should
37 * never name a file class explictly outside of the repo class. Instead use the
38 * repo's factory functions to generate file objects, for example:
39 *
40 * RepoGroup::singleton()->getLocalRepo()->newFile( $title );
41 *
42 * Consider the services container below;
43 *
44 * $services = MediaWikiServices::getInstance();
45 *
46 * The convenience services $services->getRepoGroup()->getLocalRepo()->newFile()
47 * and $services->getRepoGroup()->findFile() should be sufficient in most cases.
48 *
49 * @TODO: DI - Instead of using MediaWikiServices::getInstance(), a service should
50 * ideally accept a RepoGroup in its constructor and then, use $this->repoGroup->findFile()
51 * and $this->repoGroup->getLocalRepo()->newFile().
52 *
53 * @ingroup FileAbstraction
54 */
55 class LocalFile extends File {
56 const VERSION = 11; // cache version
57
58 const CACHE_FIELD_MAX_LEN = 1000;
59
60 /** @var bool Does the file exist on disk? (loadFromXxx) */
61 protected $fileExists;
62
63 /** @var int Image width */
64 protected $width;
65
66 /** @var int Image height */
67 protected $height;
68
69 /** @var int Returned by getimagesize (loadFromXxx) */
70 protected $bits;
71
72 /** @var string MEDIATYPE_xxx (bitmap, drawing, audio...) */
73 protected $media_type;
74
75 /** @var string MIME type, determined by MimeAnalyzer::guessMimeType */
76 protected $mime;
77
78 /** @var int Size in bytes (loadFromXxx) */
79 protected $size;
80
81 /** @var string Handler-specific metadata */
82 protected $metadata;
83
84 /** @var string SHA-1 base 36 content hash */
85 protected $sha1;
86
87 /** @var bool Whether or not core data has been loaded from the database (loadFromXxx) */
88 protected $dataLoaded;
89
90 /** @var bool Whether or not lazy-loaded data has been loaded from the database */
91 protected $extraDataLoaded;
92
93 /** @var int Bitfield akin to rev_deleted */
94 protected $deleted;
95
96 /** @var string */
97 protected $repoClass = LocalRepo::class;
98
99 /** @var int Number of line to return by nextHistoryLine() (constructor) */
100 private $historyLine;
101
102 /** @var int Result of the query for the file's history (nextHistoryLine) */
103 private $historyRes;
104
105 /** @var string Major MIME type */
106 private $major_mime;
107
108 /** @var string Minor MIME type */
109 private $minor_mime;
110
111 /** @var string Upload timestamp */
112 private $timestamp;
113
114 /** @var User Uploader */
115 private $user;
116
117 /** @var string Description of current revision of the file */
118 private $description;
119
120 /** @var string TS_MW timestamp of the last change of the file description */
121 private $descriptionTouched;
122
123 /** @var bool Whether the row was upgraded on load */
124 private $upgraded;
125
126 /** @var bool Whether the row was scheduled to upgrade on load */
127 private $upgrading;
128
129 /** @var bool True if the image row is locked */
130 private $locked;
131
132 /** @var bool True if the image row is locked with a lock initiated transaction */
133 private $lockedOwnTrx;
134
135 /** @var bool True if file is not present in file system. Not to be cached in memcached */
136 private $missing;
137
138 // @note: higher than IDBAccessObject constants
139 const LOAD_ALL = 16; // integer; load all the lazy fields too (like metadata)
140
141 const ATOMIC_SECTION_LOCK = 'LocalFile::lockingTransaction';
142
143 /**
144 * Create a LocalFile from a title
145 * Do not call this except from inside a repo class.
146 *
147 * Note: $unused param is only here to avoid an E_STRICT
148 *
149 * @param Title $title
150 * @param FileRepo $repo
151 * @param null $unused
152 *
153 * @return static
154 */
155 static function newFromTitle( $title, $repo, $unused = null ) {
156 return new static( $title, $repo );
157 }
158
159 /**
160 * Create a LocalFile from a title
161 * Do not call this except from inside a repo class.
162 *
163 * @param stdClass $row
164 * @param FileRepo $repo
165 *
166 * @return static
167 */
168 static function newFromRow( $row, $repo ) {
169 $title = Title::makeTitle( NS_FILE, $row->img_name );
170 $file = new static( $title, $repo );
171 $file->loadFromRow( $row );
172
173 return $file;
174 }
175
176 /**
177 * Create a LocalFile from a SHA-1 key
178 * Do not call this except from inside a repo class.
179 *
180 * @param string $sha1 Base-36 SHA-1
181 * @param LocalRepo $repo
182 * @param string|bool $timestamp MW_timestamp (optional)
183 * @return bool|LocalFile
184 */
185 static function newFromKey( $sha1, $repo, $timestamp = false ) {
186 $dbr = $repo->getReplicaDB();
187
188 $conds = [ 'img_sha1' => $sha1 ];
189 if ( $timestamp ) {
190 $conds['img_timestamp'] = $dbr->timestamp( $timestamp );
191 }
192
193 $fileQuery = static::getQueryInfo();
194 $row = $dbr->selectRow(
195 $fileQuery['tables'], $fileQuery['fields'], $conds, __METHOD__, [], $fileQuery['joins']
196 );
197 if ( $row ) {
198 return static::newFromRow( $row, $repo );
199 } else {
200 return false;
201 }
202 }
203
204 /**
205 * Fields in the image table
206 * @deprecated since 1.31, use self::getQueryInfo() instead.
207 * @return string[]
208 */
209 static function selectFields() {
210 global $wgActorTableSchemaMigrationStage;
211
212 wfDeprecated( __METHOD__, '1.31' );
213 if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) {
214 // If code is using this instead of self::getQueryInfo(), there's a
215 // decent chance it's going to try to directly access
216 // $row->img_user or $row->img_user_text and we can't give it
217 // useful values here once those aren't being used anymore.
218 throw new BadMethodCallException(
219 'Cannot use ' . __METHOD__
220 . ' when $wgActorTableSchemaMigrationStage has SCHEMA_COMPAT_READ_NEW'
221 );
222 }
223
224 return [
225 'img_name',
226 'img_size',
227 'img_width',
228 'img_height',
229 'img_metadata',
230 'img_bits',
231 'img_media_type',
232 'img_major_mime',
233 'img_minor_mime',
234 'img_user',
235 'img_user_text',
236 'img_actor' => 'NULL',
237 'img_timestamp',
238 'img_sha1',
239 ] + MediaWikiServices::getInstance()->getCommentStore()->getFields( 'img_description' );
240 }
241
242 /**
243 * Return the tables, fields, and join conditions to be selected to create
244 * a new localfile object.
245 * @since 1.31
246 * @param string[] $options
247 * - omit-lazy: Omit fields that are lazily cached.
248 * @return array[] With three keys:
249 * - tables: (string[]) to include in the `$table` to `IDatabase->select()`
250 * - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
251 * - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
252 */
253 public static function getQueryInfo( array $options = [] ) {
254 $commentQuery = MediaWikiServices::getInstance()->getCommentStore()->getJoin( 'img_description' );
255 $actorQuery = ActorMigration::newMigration()->getJoin( 'img_user' );
256 $ret = [
257 'tables' => [ 'image' ] + $commentQuery['tables'] + $actorQuery['tables'],
258 'fields' => [
259 'img_name',
260 'img_size',
261 'img_width',
262 'img_height',
263 'img_metadata',
264 'img_bits',
265 'img_media_type',
266 'img_major_mime',
267 'img_minor_mime',
268 'img_timestamp',
269 'img_sha1',
270 ] + $commentQuery['fields'] + $actorQuery['fields'],
271 'joins' => $commentQuery['joins'] + $actorQuery['joins'],
272 ];
273
274 if ( in_array( 'omit-nonlazy', $options, true ) ) {
275 // Internal use only for getting only the lazy fields
276 $ret['fields'] = [];
277 }
278 if ( !in_array( 'omit-lazy', $options, true ) ) {
279 // Note: Keep this in sync with self::getLazyCacheFields()
280 $ret['fields'][] = 'img_metadata';
281 }
282
283 return $ret;
284 }
285
286 /**
287 * Do not call this except from inside a repo class.
288 * @param Title $title
289 * @param FileRepo $repo
290 */
291 function __construct( $title, $repo ) {
292 parent::__construct( $title, $repo );
293
294 $this->metadata = '';
295 $this->historyLine = 0;
296 $this->historyRes = null;
297 $this->dataLoaded = false;
298 $this->extraDataLoaded = false;
299
300 $this->assertRepoDefined();
301 $this->assertTitleDefined();
302 }
303
304 /**
305 * Get the memcached key for the main data for this file, or false if
306 * there is no access to the shared cache.
307 * @return string|bool
308 */
309 function getCacheKey() {
310 return $this->repo->getSharedCacheKey( 'file', sha1( $this->getName() ) );
311 }
312
313 /**
314 * @param WANObjectCache $cache
315 * @return string[]
316 * @since 1.28
317 */
318 public function getMutableCacheKeys( WANObjectCache $cache ) {
319 return [ $this->getCacheKey() ];
320 }
321
322 /**
323 * Try to load file metadata from memcached, falling back to the database
324 */
325 private function loadFromCache() {
326 $this->dataLoaded = false;
327 $this->extraDataLoaded = false;
328
329 $key = $this->getCacheKey();
330 if ( !$key ) {
331 $this->loadFromDB( self::READ_NORMAL );
332
333 return;
334 }
335
336 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
337 $cachedValues = $cache->getWithSetCallback(
338 $key,
339 $cache::TTL_WEEK,
340 function ( $oldValue, &$ttl, array &$setOpts ) use ( $cache ) {
341 $setOpts += Database::getCacheSetOptions( $this->repo->getReplicaDB() );
342
343 $this->loadFromDB( self::READ_NORMAL );
344
345 $fields = $this->getCacheFields( '' );
346 $cacheVal['fileExists'] = $this->fileExists;
347 if ( $this->fileExists ) {
348 foreach ( $fields as $field ) {
349 $cacheVal[$field] = $this->$field;
350 }
351 }
352 $cacheVal['user'] = $this->user ? $this->user->getId() : 0;
353 $cacheVal['user_text'] = $this->user ? $this->user->getName() : '';
354 $cacheVal['actor'] = $this->user ? $this->user->getActorId() : null;
355
356 // Strip off excessive entries from the subset of fields that can become large.
357 // If the cache value gets to large it will not fit in memcached and nothing will
358 // get cached at all, causing master queries for any file access.
359 foreach ( $this->getLazyCacheFields( '' ) as $field ) {
360 if ( isset( $cacheVal[$field] )
361 && strlen( $cacheVal[$field] ) > 100 * 1024
362 ) {
363 unset( $cacheVal[$field] ); // don't let the value get too big
364 }
365 }
366
367 if ( $this->fileExists ) {
368 $ttl = $cache->adaptiveTTL( wfTimestamp( TS_UNIX, $this->timestamp ), $ttl );
369 } else {
370 $ttl = $cache::TTL_DAY;
371 }
372
373 return $cacheVal;
374 },
375 [ 'version' => self::VERSION ]
376 );
377
378 $this->fileExists = $cachedValues['fileExists'];
379 if ( $this->fileExists ) {
380 $this->setProps( $cachedValues );
381 }
382
383 $this->dataLoaded = true;
384 $this->extraDataLoaded = true;
385 foreach ( $this->getLazyCacheFields( '' ) as $field ) {
386 $this->extraDataLoaded = $this->extraDataLoaded && isset( $cachedValues[$field] );
387 }
388 }
389
390 /**
391 * Purge the file object/metadata cache
392 */
393 public function invalidateCache() {
394 $key = $this->getCacheKey();
395 if ( !$key ) {
396 return;
397 }
398
399 $this->repo->getMasterDB()->onTransactionPreCommitOrIdle(
400 function () use ( $key ) {
401 MediaWikiServices::getInstance()->getMainWANObjectCache()->delete( $key );
402 },
403 __METHOD__
404 );
405 }
406
407 /**
408 * Load metadata from the file itself
409 */
410 function loadFromFile() {
411 $props = $this->repo->getFileProps( $this->getVirtualUrl() );
412 $this->setProps( $props );
413 }
414
415 /**
416 * Returns the list of object properties that are included as-is in the cache.
417 * @param string $prefix Must be the empty string
418 * @return string[]
419 * @since 1.31 No longer accepts a non-empty $prefix
420 */
421 protected function getCacheFields( $prefix = 'img_' ) {
422 if ( $prefix !== '' ) {
423 throw new InvalidArgumentException(
424 __METHOD__ . ' with a non-empty prefix is no longer supported.'
425 );
426 }
427
428 // See self::getQueryInfo() for the fetching of the data from the DB,
429 // self::loadFromRow() for the loading of the object from the DB row,
430 // and self::loadFromCache() for the caching, and self::setProps() for
431 // populating the object from an array of data.
432 return [ 'size', 'width', 'height', 'bits', 'media_type',
433 'major_mime', 'minor_mime', 'metadata', 'timestamp', 'sha1', 'description' ];
434 }
435
436 /**
437 * Returns the list of object properties that are included as-is in the
438 * cache, only when they're not too big, and are lazily loaded by self::loadExtraFromDB().
439 * @param string $prefix Must be the empty string
440 * @return string[]
441 * @since 1.31 No longer accepts a non-empty $prefix
442 */
443 protected function getLazyCacheFields( $prefix = 'img_' ) {
444 if ( $prefix !== '' ) {
445 throw new InvalidArgumentException(
446 __METHOD__ . ' with a non-empty prefix is no longer supported.'
447 );
448 }
449
450 // Keep this in sync with the omit-lazy option in self::getQueryInfo().
451 return [ 'metadata' ];
452 }
453
454 /**
455 * Load file metadata from the DB
456 * @param int $flags
457 */
458 function loadFromDB( $flags = 0 ) {
459 $fname = static::class . '::' . __FUNCTION__;
460
461 # Unconditionally set loaded=true, we don't want the accessors constantly rechecking
462 $this->dataLoaded = true;
463 $this->extraDataLoaded = true;
464
465 $dbr = ( $flags & self::READ_LATEST )
466 ? $this->repo->getMasterDB()
467 : $this->repo->getReplicaDB();
468
469 $fileQuery = static::getQueryInfo();
470 $row = $dbr->selectRow(
471 $fileQuery['tables'],
472 $fileQuery['fields'],
473 [ 'img_name' => $this->getName() ],
474 $fname,
475 [],
476 $fileQuery['joins']
477 );
478
479 if ( $row ) {
480 $this->loadFromRow( $row );
481 } else {
482 $this->fileExists = false;
483 }
484 }
485
486 /**
487 * Load lazy file metadata from the DB.
488 * This covers fields that are sometimes not cached.
489 */
490 protected function loadExtraFromDB() {
491 $fname = static::class . '::' . __FUNCTION__;
492
493 # Unconditionally set loaded=true, we don't want the accessors constantly rechecking
494 $this->extraDataLoaded = true;
495
496 $fieldMap = $this->loadExtraFieldsWithTimestamp( $this->repo->getReplicaDB(), $fname );
497 if ( !$fieldMap ) {
498 $fieldMap = $this->loadExtraFieldsWithTimestamp( $this->repo->getMasterDB(), $fname );
499 }
500
501 if ( $fieldMap ) {
502 foreach ( $fieldMap as $name => $value ) {
503 $this->$name = $value;
504 }
505 } else {
506 throw new MWException( "Could not find data for image '{$this->getName()}'." );
507 }
508 }
509
510 /**
511 * @param IDatabase $dbr
512 * @param string $fname
513 * @return string[]|bool
514 */
515 private function loadExtraFieldsWithTimestamp( $dbr, $fname ) {
516 $fieldMap = false;
517
518 $fileQuery = self::getQueryInfo( [ 'omit-nonlazy' ] );
519 $row = $dbr->selectRow(
520 $fileQuery['tables'],
521 $fileQuery['fields'],
522 [
523 'img_name' => $this->getName(),
524 'img_timestamp' => $dbr->timestamp( $this->getTimestamp() ),
525 ],
526 $fname,
527 [],
528 $fileQuery['joins']
529 );
530 if ( $row ) {
531 $fieldMap = $this->unprefixRow( $row, 'img_' );
532 } else {
533 # File may have been uploaded over in the meantime; check the old versions
534 $fileQuery = OldLocalFile::getQueryInfo( [ 'omit-nonlazy' ] );
535 $row = $dbr->selectRow(
536 $fileQuery['tables'],
537 $fileQuery['fields'],
538 [
539 'oi_name' => $this->getName(),
540 'oi_timestamp' => $dbr->timestamp( $this->getTimestamp() ),
541 ],
542 $fname,
543 [],
544 $fileQuery['joins']
545 );
546 if ( $row ) {
547 $fieldMap = $this->unprefixRow( $row, 'oi_' );
548 }
549 }
550
551 if ( isset( $fieldMap['metadata'] ) ) {
552 $fieldMap['metadata'] = $this->repo->getReplicaDB()->decodeBlob( $fieldMap['metadata'] );
553 }
554
555 return $fieldMap;
556 }
557
558 /**
559 * @param array|object $row
560 * @param string $prefix
561 * @throws MWException
562 * @return array
563 */
564 protected function unprefixRow( $row, $prefix = 'img_' ) {
565 $array = (array)$row;
566 $prefixLength = strlen( $prefix );
567
568 // Sanity check prefix once
569 if ( substr( key( $array ), 0, $prefixLength ) !== $prefix ) {
570 throw new MWException( __METHOD__ . ': incorrect $prefix parameter' );
571 }
572
573 $decoded = [];
574 foreach ( $array as $name => $value ) {
575 $decoded[substr( $name, $prefixLength )] = $value;
576 }
577
578 return $decoded;
579 }
580
581 /**
582 * Decode a row from the database (either object or array) to an array
583 * with timestamps and MIME types decoded, and the field prefix removed.
584 * @param object $row
585 * @param string $prefix
586 * @throws MWException
587 * @return array
588 */
589 function decodeRow( $row, $prefix = 'img_' ) {
590 $decoded = $this->unprefixRow( $row, $prefix );
591
592 $decoded['description'] = MediaWikiServices::getInstance()->getCommentStore()
593 ->getComment( 'description', (object)$decoded )->text;
594
595 $decoded['user'] = User::newFromAnyId(
596 $decoded['user'] ?? null,
597 $decoded['user_text'] ?? null,
598 $decoded['actor'] ?? null
599 );
600 unset( $decoded['user_text'], $decoded['actor'] );
601
602 $decoded['timestamp'] = wfTimestamp( TS_MW, $decoded['timestamp'] );
603
604 $decoded['metadata'] = $this->repo->getReplicaDB()->decodeBlob( $decoded['metadata'] );
605
606 if ( empty( $decoded['major_mime'] ) ) {
607 $decoded['mime'] = 'unknown/unknown';
608 } else {
609 if ( !$decoded['minor_mime'] ) {
610 $decoded['minor_mime'] = 'unknown';
611 }
612 $decoded['mime'] = $decoded['major_mime'] . '/' . $decoded['minor_mime'];
613 }
614
615 // Trim zero padding from char/binary field
616 $decoded['sha1'] = rtrim( $decoded['sha1'], "\0" );
617
618 // Normalize some fields to integer type, per their database definition.
619 // Use unary + so that overflows will be upgraded to double instead of
620 // being trucated as with intval(). This is important to allow >2GB
621 // files on 32-bit systems.
622 foreach ( [ 'size', 'width', 'height', 'bits' ] as $field ) {
623 $decoded[$field] = +$decoded[$field];
624 }
625
626 return $decoded;
627 }
628
629 /**
630 * Load file metadata from a DB result row
631 *
632 * @param object $row
633 * @param string $prefix
634 */
635 function loadFromRow( $row, $prefix = 'img_' ) {
636 $this->dataLoaded = true;
637 $this->extraDataLoaded = true;
638
639 $array = $this->decodeRow( $row, $prefix );
640
641 foreach ( $array as $name => $value ) {
642 $this->$name = $value;
643 }
644
645 $this->fileExists = true;
646 }
647
648 /**
649 * Load file metadata from cache or DB, unless already loaded
650 * @param int $flags
651 */
652 function load( $flags = 0 ) {
653 if ( !$this->dataLoaded ) {
654 if ( $flags & self::READ_LATEST ) {
655 $this->loadFromDB( $flags );
656 } else {
657 $this->loadFromCache();
658 }
659 }
660
661 if ( ( $flags & self::LOAD_ALL ) && !$this->extraDataLoaded ) {
662 // @note: loads on name/timestamp to reduce race condition problems
663 $this->loadExtraFromDB();
664 }
665 }
666
667 /**
668 * Upgrade a row if it needs it
669 */
670 protected function maybeUpgradeRow() {
671 global $wgUpdateCompatibleMetadata;
672
673 if ( wfReadOnly() || $this->upgrading ) {
674 return;
675 }
676
677 $upgrade = false;
678 if ( is_null( $this->media_type ) || $this->mime == 'image/svg' ) {
679 $upgrade = true;
680 } else {
681 $handler = $this->getHandler();
682 if ( $handler ) {
683 $validity = $handler->isMetadataValid( $this, $this->getMetadata() );
684 if ( $validity === MediaHandler::METADATA_BAD ) {
685 $upgrade = true;
686 } elseif ( $validity === MediaHandler::METADATA_COMPATIBLE ) {
687 $upgrade = $wgUpdateCompatibleMetadata;
688 }
689 }
690 }
691
692 if ( $upgrade ) {
693 $this->upgrading = true;
694 // Defer updates unless in auto-commit CLI mode
695 DeferredUpdates::addCallableUpdate( function () {
696 $this->upgrading = false; // avoid duplicate updates
697 try {
698 $this->upgradeRow();
699 } catch ( LocalFileLockError $e ) {
700 // let the other process handle it (or do it next time)
701 }
702 } );
703 }
704 }
705
706 /**
707 * @return bool Whether upgradeRow() ran for this object
708 */
709 function getUpgraded() {
710 return $this->upgraded;
711 }
712
713 /**
714 * Fix assorted version-related problems with the image row by reloading it from the file
715 */
716 function upgradeRow() {
717 $this->lock();
718
719 $this->loadFromFile();
720
721 # Don't destroy file info of missing files
722 if ( !$this->fileExists ) {
723 $this->unlock();
724 wfDebug( __METHOD__ . ": file does not exist, aborting\n" );
725
726 return;
727 }
728
729 $dbw = $this->repo->getMasterDB();
730 list( $major, $minor ) = self::splitMime( $this->mime );
731
732 if ( wfReadOnly() ) {
733 $this->unlock();
734
735 return;
736 }
737 wfDebug( __METHOD__ . ': upgrading ' . $this->getName() . " to the current schema\n" );
738
739 $dbw->update( 'image',
740 [
741 'img_size' => $this->size, // sanity
742 'img_width' => $this->width,
743 'img_height' => $this->height,
744 'img_bits' => $this->bits,
745 'img_media_type' => $this->media_type,
746 'img_major_mime' => $major,
747 'img_minor_mime' => $minor,
748 'img_metadata' => $dbw->encodeBlob( $this->metadata ),
749 'img_sha1' => $this->sha1,
750 ],
751 [ 'img_name' => $this->getName() ],
752 __METHOD__
753 );
754
755 $this->invalidateCache();
756
757 $this->unlock();
758 $this->upgraded = true; // avoid rework/retries
759 }
760
761 /**
762 * Set properties in this object to be equal to those given in the
763 * associative array $info. Only cacheable fields can be set.
764 * All fields *must* be set in $info except for getLazyCacheFields().
765 *
766 * If 'mime' is given, it will be split into major_mime/minor_mime.
767 * If major_mime/minor_mime are given, $this->mime will also be set.
768 *
769 * @param array $info
770 */
771 function setProps( $info ) {
772 $this->dataLoaded = true;
773 $fields = $this->getCacheFields( '' );
774 $fields[] = 'fileExists';
775
776 foreach ( $fields as $field ) {
777 if ( isset( $info[$field] ) ) {
778 $this->$field = $info[$field];
779 }
780 }
781
782 if ( isset( $info['user'] ) || isset( $info['user_text'] ) || isset( $info['actor'] ) ) {
783 $this->user = User::newFromAnyId(
784 $info['user'] ?? null,
785 $info['user_text'] ?? null,
786 $info['actor'] ?? null
787 );
788 }
789
790 // Fix up mime fields
791 if ( isset( $info['major_mime'] ) ) {
792 $this->mime = "{$info['major_mime']}/{$info['minor_mime']}";
793 } elseif ( isset( $info['mime'] ) ) {
794 $this->mime = $info['mime'];
795 list( $this->major_mime, $this->minor_mime ) = self::splitMime( $this->mime );
796 }
797 }
798
799 /** splitMime inherited */
800 /** getName inherited */
801 /** getTitle inherited */
802 /** getURL inherited */
803 /** getViewURL inherited */
804 /** getPath inherited */
805 /** isVisible inherited */
806
807 /**
808 * Checks if this file exists in its parent repo, as referenced by its
809 * virtual URL.
810 *
811 * @return bool
812 */
813 function isMissing() {
814 if ( $this->missing === null ) {
815 $fileExists = $this->repo->fileExists( $this->getVirtualUrl() );
816 $this->missing = !$fileExists;
817 }
818
819 return $this->missing;
820 }
821
822 /**
823 * Return the width of the image
824 *
825 * @param int $page
826 * @return int
827 */
828 public function getWidth( $page = 1 ) {
829 $page = (int)$page;
830 if ( $page < 1 ) {
831 $page = 1;
832 }
833
834 $this->load();
835
836 if ( $this->isMultipage() ) {
837 $handler = $this->getHandler();
838 if ( !$handler ) {
839 return 0;
840 }
841 $dim = $handler->getPageDimensions( $this, $page );
842 if ( $dim ) {
843 return $dim['width'];
844 } else {
845 // For non-paged media, the false goes through an
846 // intval, turning failure into 0, so do same here.
847 return 0;
848 }
849 } else {
850 return $this->width;
851 }
852 }
853
854 /**
855 * Return the height of the image
856 *
857 * @param int $page
858 * @return int
859 */
860 public function getHeight( $page = 1 ) {
861 $page = (int)$page;
862 if ( $page < 1 ) {
863 $page = 1;
864 }
865
866 $this->load();
867
868 if ( $this->isMultipage() ) {
869 $handler = $this->getHandler();
870 if ( !$handler ) {
871 return 0;
872 }
873 $dim = $handler->getPageDimensions( $this, $page );
874 if ( $dim ) {
875 return $dim['height'];
876 } else {
877 // For non-paged media, the false goes through an
878 // intval, turning failure into 0, so do same here.
879 return 0;
880 }
881 } else {
882 return $this->height;
883 }
884 }
885
886 /**
887 * Returns user who uploaded the file
888 *
889 * @param string $type 'text', 'id', or 'object'
890 * @return int|string|User
891 * @since 1.31 Added 'object'
892 */
893 function getUser( $type = 'text' ) {
894 $this->load();
895
896 if ( $type === 'object' ) {
897 return $this->user;
898 } elseif ( $type === 'text' ) {
899 return $this->user->getName();
900 } elseif ( $type === 'id' ) {
901 return $this->user->getId();
902 }
903
904 throw new MWException( "Unknown type '$type'." );
905 }
906
907 /**
908 * Get short description URL for a file based on the page ID.
909 *
910 * @return string|null
911 * @throws MWException
912 * @since 1.27
913 */
914 public function getDescriptionShortUrl() {
915 $pageId = $this->title->getArticleID();
916
917 if ( $pageId !== null ) {
918 $url = $this->repo->makeUrl( [ 'curid' => $pageId ] );
919 if ( $url !== false ) {
920 return $url;
921 }
922 }
923 return null;
924 }
925
926 /**
927 * Get handler-specific metadata
928 * @return string
929 */
930 function getMetadata() {
931 $this->load( self::LOAD_ALL ); // large metadata is loaded in another step
932 return $this->metadata;
933 }
934
935 /**
936 * @return int
937 */
938 function getBitDepth() {
939 $this->load();
940
941 return (int)$this->bits;
942 }
943
944 /**
945 * Returns the size of the image file, in bytes
946 * @return int
947 */
948 public function getSize() {
949 $this->load();
950
951 return $this->size;
952 }
953
954 /**
955 * Returns the MIME type of the file.
956 * @return string
957 */
958 function getMimeType() {
959 $this->load();
960
961 return $this->mime;
962 }
963
964 /**
965 * Returns the type of the media in the file.
966 * Use the value returned by this function with the MEDIATYPE_xxx constants.
967 * @return string
968 */
969 function getMediaType() {
970 $this->load();
971
972 return $this->media_type;
973 }
974
975 /** canRender inherited */
976 /** mustRender inherited */
977 /** allowInlineDisplay inherited */
978 /** isSafeFile inherited */
979 /** isTrustedFile inherited */
980
981 /**
982 * Returns true if the file exists on disk.
983 * @return bool Whether file exist on disk.
984 */
985 public function exists() {
986 $this->load();
987
988 return $this->fileExists;
989 }
990
991 /** getTransformScript inherited */
992 /** getUnscaledThumb inherited */
993 /** thumbName inherited */
994 /** createThumb inherited */
995 /** transform inherited */
996
997 /** getHandler inherited */
998 /** iconThumb inherited */
999 /** getLastError inherited */
1000
1001 /**
1002 * Get all thumbnail names previously generated for this file
1003 * @param string|bool $archiveName Name of an archive file, default false
1004 * @return array First element is the base dir, then files in that base dir.
1005 */
1006 function getThumbnails( $archiveName = false ) {
1007 if ( $archiveName ) {
1008 $dir = $this->getArchiveThumbPath( $archiveName );
1009 } else {
1010 $dir = $this->getThumbPath();
1011 }
1012
1013 $backend = $this->repo->getBackend();
1014 $files = [ $dir ];
1015 try {
1016 $iterator = $backend->getFileList( [ 'dir' => $dir ] );
1017 foreach ( $iterator as $file ) {
1018 $files[] = $file;
1019 }
1020 } catch ( FileBackendError $e ) {
1021 } // suppress (T56674)
1022
1023 return $files;
1024 }
1025
1026 /**
1027 * Refresh metadata in memcached, but don't touch thumbnails or CDN
1028 */
1029 function purgeMetadataCache() {
1030 $this->invalidateCache();
1031 }
1032
1033 /**
1034 * Delete all previously generated thumbnails, refresh metadata in memcached and purge the CDN.
1035 *
1036 * @param array $options An array potentially with the key forThumbRefresh.
1037 *
1038 * @note This used to purge old thumbnails by default as well, but doesn't anymore.
1039 */
1040 function purgeCache( $options = [] ) {
1041 // Refresh metadata cache
1042 $this->maybeUpgradeRow();
1043 $this->purgeMetadataCache();
1044
1045 // Delete thumbnails
1046 $this->purgeThumbnails( $options );
1047
1048 // Purge CDN cache for this file
1049 DeferredUpdates::addUpdate(
1050 new CdnCacheUpdate( [ $this->getUrl() ] ),
1051 DeferredUpdates::PRESEND
1052 );
1053 }
1054
1055 /**
1056 * Delete cached transformed files for an archived version only.
1057 * @param string $archiveName Name of the archived file
1058 */
1059 function purgeOldThumbnails( $archiveName ) {
1060 // Get a list of old thumbnails and URLs
1061 $files = $this->getThumbnails( $archiveName );
1062
1063 // Purge any custom thumbnail caches
1064 Hooks::run( 'LocalFilePurgeThumbnails', [ $this, $archiveName ] );
1065
1066 // Delete thumbnails
1067 $dir = array_shift( $files );
1068 $this->purgeThumbList( $dir, $files );
1069
1070 // Purge the CDN
1071 $urls = [];
1072 foreach ( $files as $file ) {
1073 $urls[] = $this->getArchiveThumbUrl( $archiveName, $file );
1074 }
1075 DeferredUpdates::addUpdate( new CdnCacheUpdate( $urls ), DeferredUpdates::PRESEND );
1076 }
1077
1078 /**
1079 * Delete cached transformed files for the current version only.
1080 * @param array $options
1081 */
1082 public function purgeThumbnails( $options = [] ) {
1083 $files = $this->getThumbnails();
1084 // Always purge all files from CDN regardless of handler filters
1085 $urls = [];
1086 foreach ( $files as $file ) {
1087 $urls[] = $this->getThumbUrl( $file );
1088 }
1089 array_shift( $urls ); // don't purge directory
1090
1091 // Give media handler a chance to filter the file purge list
1092 if ( !empty( $options['forThumbRefresh'] ) ) {
1093 $handler = $this->getHandler();
1094 if ( $handler ) {
1095 $handler->filterThumbnailPurgeList( $files, $options );
1096 }
1097 }
1098
1099 // Purge any custom thumbnail caches
1100 Hooks::run( 'LocalFilePurgeThumbnails', [ $this, false ] );
1101
1102 // Delete thumbnails
1103 $dir = array_shift( $files );
1104 $this->purgeThumbList( $dir, $files );
1105
1106 // Purge the CDN
1107 DeferredUpdates::addUpdate( new CdnCacheUpdate( $urls ), DeferredUpdates::PRESEND );
1108 }
1109
1110 /**
1111 * Prerenders a configurable set of thumbnails
1112 *
1113 * @since 1.28
1114 */
1115 public function prerenderThumbnails() {
1116 global $wgUploadThumbnailRenderMap;
1117
1118 $jobs = [];
1119
1120 $sizes = $wgUploadThumbnailRenderMap;
1121 rsort( $sizes );
1122
1123 foreach ( $sizes as $size ) {
1124 if ( $this->isVectorized() || $this->getWidth() > $size ) {
1125 $jobs[] = new ThumbnailRenderJob(
1126 $this->getTitle(),
1127 [ 'transformParams' => [ 'width' => $size ] ]
1128 );
1129 }
1130 }
1131
1132 if ( $jobs ) {
1133 JobQueueGroup::singleton()->lazyPush( $jobs );
1134 }
1135 }
1136
1137 /**
1138 * Delete a list of thumbnails visible at urls
1139 * @param string $dir Base dir of the files.
1140 * @param array $files Array of strings: relative filenames (to $dir)
1141 */
1142 protected function purgeThumbList( $dir, $files ) {
1143 $fileListDebug = strtr(
1144 var_export( $files, true ),
1145 [ "\n" => '' ]
1146 );
1147 wfDebug( __METHOD__ . ": $fileListDebug\n" );
1148
1149 $purgeList = [];
1150 foreach ( $files as $file ) {
1151 if ( $this->repo->supportsSha1URLs() ) {
1152 $reference = $this->getSha1();
1153 } else {
1154 $reference = $this->getName();
1155 }
1156
1157 # Check that the reference (filename or sha1) is part of the thumb name
1158 # This is a basic sanity check to avoid erasing unrelated directories
1159 if ( strpos( $file, $reference ) !== false
1160 || strpos( $file, "-thumbnail" ) !== false // "short" thumb name
1161 ) {
1162 $purgeList[] = "{$dir}/{$file}";
1163 }
1164 }
1165
1166 # Delete the thumbnails
1167 $this->repo->quickPurgeBatch( $purgeList );
1168 # Clear out the thumbnail directory if empty
1169 $this->repo->quickCleanDir( $dir );
1170 }
1171
1172 /** purgeDescription inherited */
1173 /** purgeEverything inherited */
1174
1175 /**
1176 * @param int|null $limit Optional: Limit to number of results
1177 * @param string|int|null $start Optional: Timestamp, start from
1178 * @param string|int|null $end Optional: Timestamp, end at
1179 * @param bool $inc
1180 * @return OldLocalFile[]
1181 */
1182 function getHistory( $limit = null, $start = null, $end = null, $inc = true ) {
1183 $dbr = $this->repo->getReplicaDB();
1184 $oldFileQuery = OldLocalFile::getQueryInfo();
1185
1186 $tables = $oldFileQuery['tables'];
1187 $fields = $oldFileQuery['fields'];
1188 $join_conds = $oldFileQuery['joins'];
1189 $conds = $opts = [];
1190 $eq = $inc ? '=' : '';
1191 $conds[] = "oi_name = " . $dbr->addQuotes( $this->title->getDBkey() );
1192
1193 if ( $start ) {
1194 $conds[] = "oi_timestamp <$eq " . $dbr->addQuotes( $dbr->timestamp( $start ) );
1195 }
1196
1197 if ( $end ) {
1198 $conds[] = "oi_timestamp >$eq " . $dbr->addQuotes( $dbr->timestamp( $end ) );
1199 }
1200
1201 if ( $limit ) {
1202 $opts['LIMIT'] = $limit;
1203 }
1204
1205 // Search backwards for time > x queries
1206 $order = ( !$start && $end !== null ) ? 'ASC' : 'DESC';
1207 $opts['ORDER BY'] = "oi_timestamp $order";
1208 $opts['USE INDEX'] = [ 'oldimage' => 'oi_name_timestamp' ];
1209
1210 // Avoid PHP 7.1 warning from passing $this by reference
1211 $localFile = $this;
1212 Hooks::run( 'LocalFile::getHistory', [ &$localFile, &$tables, &$fields,
1213 &$conds, &$opts, &$join_conds ] );
1214
1215 $res = $dbr->select( $tables, $fields, $conds, __METHOD__, $opts, $join_conds );
1216 $r = [];
1217
1218 foreach ( $res as $row ) {
1219 $r[] = $this->repo->newFileFromRow( $row );
1220 }
1221
1222 if ( $order == 'ASC' ) {
1223 $r = array_reverse( $r ); // make sure it ends up descending
1224 }
1225
1226 return $r;
1227 }
1228
1229 /**
1230 * Returns the history of this file, line by line.
1231 * starts with current version, then old versions.
1232 * uses $this->historyLine to check which line to return:
1233 * 0 return line for current version
1234 * 1 query for old versions, return first one
1235 * 2, ... return next old version from above query
1236 * @return bool
1237 */
1238 public function nextHistoryLine() {
1239 # Polymorphic function name to distinguish foreign and local fetches
1240 $fname = static::class . '::' . __FUNCTION__;
1241
1242 $dbr = $this->repo->getReplicaDB();
1243
1244 if ( $this->historyLine == 0 ) { // called for the first time, return line from cur
1245 $fileQuery = self::getQueryInfo();
1246 $this->historyRes = $dbr->select( $fileQuery['tables'],
1247 $fileQuery['fields'] + [
1248 'oi_archive_name' => $dbr->addQuotes( '' ),
1249 'oi_deleted' => 0,
1250 ],
1251 [ 'img_name' => $this->title->getDBkey() ],
1252 $fname,
1253 [],
1254 $fileQuery['joins']
1255 );
1256
1257 if ( $dbr->numRows( $this->historyRes ) == 0 ) {
1258 $this->historyRes = null;
1259
1260 return false;
1261 }
1262 } elseif ( $this->historyLine == 1 ) {
1263 $fileQuery = OldLocalFile::getQueryInfo();
1264 $this->historyRes = $dbr->select(
1265 $fileQuery['tables'],
1266 $fileQuery['fields'],
1267 [ 'oi_name' => $this->title->getDBkey() ],
1268 $fname,
1269 [ 'ORDER BY' => 'oi_timestamp DESC' ],
1270 $fileQuery['joins']
1271 );
1272 }
1273 $this->historyLine++;
1274
1275 return $dbr->fetchObject( $this->historyRes );
1276 }
1277
1278 /**
1279 * Reset the history pointer to the first element of the history
1280 */
1281 public function resetHistory() {
1282 $this->historyLine = 0;
1283
1284 if ( !is_null( $this->historyRes ) ) {
1285 $this->historyRes = null;
1286 }
1287 }
1288
1289 /** getHashPath inherited */
1290 /** getRel inherited */
1291 /** getUrlRel inherited */
1292 /** getArchiveRel inherited */
1293 /** getArchivePath inherited */
1294 /** getThumbPath inherited */
1295 /** getArchiveUrl inherited */
1296 /** getThumbUrl inherited */
1297 /** getArchiveVirtualUrl inherited */
1298 /** getThumbVirtualUrl inherited */
1299 /** isHashed inherited */
1300
1301 /**
1302 * Upload a file and record it in the DB
1303 * @param string|FSFile $src Source storage path, virtual URL, or filesystem path
1304 * @param string $comment Upload description
1305 * @param string $pageText Text to use for the new description page,
1306 * if a new description page is created
1307 * @param int|bool $flags Flags for publish()
1308 * @param array|bool $props File properties, if known. This can be used to
1309 * reduce the upload time when uploading virtual URLs for which the file
1310 * info is already known
1311 * @param string|bool $timestamp Timestamp for img_timestamp, or false to use the
1312 * current time
1313 * @param User|null $user User object or null to use $wgUser
1314 * @param string[] $tags Change tags to add to the log entry and page revision.
1315 * (This doesn't check $user's permissions.)
1316 * @param bool $createNullRevision Set to false to avoid creation of a null revision on file
1317 * upload, see T193621
1318 * @param bool $revert If this file upload is a revert
1319 * @return Status On success, the value member contains the
1320 * archive name, or an empty string if it was a new file.
1321 */
1322 function upload( $src, $comment, $pageText, $flags = 0, $props = false,
1323 $timestamp = false, $user = null, $tags = [],
1324 $createNullRevision = true, $revert = false
1325 ) {
1326 if ( $this->getRepo()->getReadOnlyReason() !== false ) {
1327 return $this->readOnlyFatalStatus();
1328 } elseif ( MediaWikiServices::getInstance()->getRevisionStore()->isReadOnly() ) {
1329 // Check this in advance to avoid writing to FileBackend and the file tables,
1330 // only to fail on insert the revision due to the text store being unavailable.
1331 return $this->readOnlyFatalStatus();
1332 }
1333
1334 $srcPath = ( $src instanceof FSFile ) ? $src->getPath() : $src;
1335 if ( !$props ) {
1336 if ( FileRepo::isVirtualUrl( $srcPath )
1337 || FileBackend::isStoragePath( $srcPath )
1338 ) {
1339 $props = $this->repo->getFileProps( $srcPath );
1340 } else {
1341 $mwProps = new MWFileProps( MediaWikiServices::getInstance()->getMimeAnalyzer() );
1342 $props = $mwProps->getPropsFromPath( $srcPath, true );
1343 }
1344 }
1345
1346 $options = [];
1347 $handler = MediaHandler::getHandler( $props['mime'] );
1348 if ( $handler ) {
1349 $metadata = AtEase::quietCall( 'unserialize', $props['metadata'] );
1350
1351 if ( !is_array( $metadata ) ) {
1352 $metadata = [];
1353 }
1354
1355 $options['headers'] = $handler->getContentHeaders( $metadata );
1356 } else {
1357 $options['headers'] = [];
1358 }
1359
1360 // Trim spaces on user supplied text
1361 $comment = trim( $comment );
1362
1363 $this->lock();
1364 $status = $this->publish( $src, $flags, $options );
1365
1366 if ( $status->successCount >= 2 ) {
1367 // There will be a copy+(one of move,copy,store).
1368 // The first succeeding does not commit us to updating the DB
1369 // since it simply copied the current version to a timestamped file name.
1370 // It is only *preferable* to avoid leaving such files orphaned.
1371 // Once the second operation goes through, then the current version was
1372 // updated and we must therefore update the DB too.
1373 $oldver = $status->value;
1374 $uploadStatus = $this->recordUpload2(
1375 $oldver,
1376 $comment,
1377 $pageText,
1378 $props,
1379 $timestamp,
1380 $user,
1381 $tags,
1382 $createNullRevision,
1383 $revert
1384 );
1385 if ( !$uploadStatus->isOK() ) {
1386 if ( $uploadStatus->hasMessage( 'filenotfound' ) ) {
1387 // update filenotfound error with more specific path
1388 $status->fatal( 'filenotfound', $srcPath );
1389 } else {
1390 $status->merge( $uploadStatus );
1391 }
1392 }
1393 }
1394
1395 $this->unlock();
1396 return $status;
1397 }
1398
1399 /**
1400 * Record a file upload in the upload log and the image table
1401 * @param string $oldver
1402 * @param string $desc
1403 * @param string $license
1404 * @param string $copyStatus
1405 * @param string $source
1406 * @param bool $watch
1407 * @param string|bool $timestamp
1408 * @param User|null $user User object or null to use $wgUser
1409 * @return bool
1410 */
1411 function recordUpload( $oldver, $desc, $license = '', $copyStatus = '', $source = '',
1412 $watch = false, $timestamp = false, User $user = null ) {
1413 if ( !$user ) {
1414 global $wgUser;
1415 $user = $wgUser;
1416 }
1417
1418 $pageText = SpecialUpload::getInitialPageText( $desc, $license, $copyStatus, $source );
1419
1420 if ( !$this->recordUpload2( $oldver, $desc, $pageText, false, $timestamp, $user )->isOK() ) {
1421 return false;
1422 }
1423
1424 if ( $watch ) {
1425 $user->addWatch( $this->getTitle() );
1426 }
1427
1428 return true;
1429 }
1430
1431 /**
1432 * Record a file upload in the upload log and the image table
1433 * @param string $oldver
1434 * @param string $comment
1435 * @param string $pageText
1436 * @param bool|array $props
1437 * @param string|bool $timestamp
1438 * @param null|User $user
1439 * @param string[] $tags
1440 * @param bool $createNullRevision Set to false to avoid creation of a null revision on file
1441 * upload, see T193621
1442 * @param bool $revert If this file upload is a revert
1443 * @return Status
1444 */
1445 function recordUpload2(
1446 $oldver, $comment, $pageText, $props = false, $timestamp = false, $user = null, $tags = [],
1447 $createNullRevision = true, $revert = false
1448 ) {
1449 global $wgActorTableSchemaMigrationStage;
1450
1451 if ( is_null( $user ) ) {
1452 global $wgUser;
1453 $user = $wgUser;
1454 }
1455
1456 $dbw = $this->repo->getMasterDB();
1457
1458 # Imports or such might force a certain timestamp; otherwise we generate
1459 # it and can fudge it slightly to keep (name,timestamp) unique on re-upload.
1460 if ( $timestamp === false ) {
1461 $timestamp = $dbw->timestamp();
1462 $allowTimeKludge = true;
1463 } else {
1464 $allowTimeKludge = false;
1465 }
1466
1467 $props = $props ?: $this->repo->getFileProps( $this->getVirtualUrl() );
1468 $props['description'] = $comment;
1469 $props['user'] = $user->getId();
1470 $props['user_text'] = $user->getName();
1471 $props['actor'] = $user->getActorId( $dbw );
1472 $props['timestamp'] = wfTimestamp( TS_MW, $timestamp ); // DB -> TS_MW
1473 $this->setProps( $props );
1474
1475 # Fail now if the file isn't there
1476 if ( !$this->fileExists ) {
1477 wfDebug( __METHOD__ . ": File " . $this->getRel() . " went missing!\n" );
1478
1479 return Status::newFatal( 'filenotfound', $this->getRel() );
1480 }
1481
1482 $dbw->startAtomic( __METHOD__ );
1483
1484 # Test to see if the row exists using INSERT IGNORE
1485 # This avoids race conditions by locking the row until the commit, and also
1486 # doesn't deadlock. SELECT FOR UPDATE causes a deadlock for every race condition.
1487 $commentStore = MediaWikiServices::getInstance()->getCommentStore();
1488 $commentFields = $commentStore->insert( $dbw, 'img_description', $comment );
1489 $actorMigration = ActorMigration::newMigration();
1490 $actorFields = $actorMigration->getInsertValues( $dbw, 'img_user', $user );
1491 $dbw->insert( 'image',
1492 [
1493 'img_name' => $this->getName(),
1494 'img_size' => $this->size,
1495 'img_width' => intval( $this->width ),
1496 'img_height' => intval( $this->height ),
1497 'img_bits' => $this->bits,
1498 'img_media_type' => $this->media_type,
1499 'img_major_mime' => $this->major_mime,
1500 'img_minor_mime' => $this->minor_mime,
1501 'img_timestamp' => $timestamp,
1502 'img_metadata' => $dbw->encodeBlob( $this->metadata ),
1503 'img_sha1' => $this->sha1
1504 ] + $commentFields + $actorFields,
1505 __METHOD__,
1506 [ 'IGNORE' ]
1507 );
1508 $reupload = ( $dbw->affectedRows() == 0 );
1509
1510 if ( $reupload ) {
1511 $row = $dbw->selectRow(
1512 'image',
1513 [ 'img_timestamp', 'img_sha1' ],
1514 [ 'img_name' => $this->getName() ],
1515 __METHOD__,
1516 [ 'LOCK IN SHARE MODE' ]
1517 );
1518
1519 if ( $row && $row->img_sha1 === $this->sha1 ) {
1520 $dbw->endAtomic( __METHOD__ );
1521 wfDebug( __METHOD__ . ": File " . $this->getRel() . " already exists!\n" );
1522 $title = Title::newFromText( $this->getName(), NS_FILE );
1523 return Status::newFatal( 'fileexists-no-change', $title->getPrefixedText() );
1524 }
1525
1526 if ( $allowTimeKludge ) {
1527 # Use LOCK IN SHARE MODE to ignore any transaction snapshotting
1528 $lUnixtime = $row ? wfTimestamp( TS_UNIX, $row->img_timestamp ) : false;
1529 # Avoid a timestamp that is not newer than the last version
1530 # TODO: the image/oldimage tables should be like page/revision with an ID field
1531 if ( $lUnixtime && wfTimestamp( TS_UNIX, $timestamp ) <= $lUnixtime ) {
1532 sleep( 1 ); // fast enough re-uploads would go far in the future otherwise
1533 $timestamp = $dbw->timestamp( $lUnixtime + 1 );
1534 $this->timestamp = wfTimestamp( TS_MW, $timestamp ); // DB -> TS_MW
1535 }
1536 }
1537
1538 $tables = [ 'image' ];
1539 $fields = [
1540 'oi_name' => 'img_name',
1541 'oi_archive_name' => $dbw->addQuotes( $oldver ),
1542 'oi_size' => 'img_size',
1543 'oi_width' => 'img_width',
1544 'oi_height' => 'img_height',
1545 'oi_bits' => 'img_bits',
1546 'oi_description_id' => 'img_description_id',
1547 'oi_timestamp' => 'img_timestamp',
1548 'oi_metadata' => 'img_metadata',
1549 'oi_media_type' => 'img_media_type',
1550 'oi_major_mime' => 'img_major_mime',
1551 'oi_minor_mime' => 'img_minor_mime',
1552 'oi_sha1' => 'img_sha1',
1553 ];
1554 $joins = [];
1555
1556 if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_OLD ) {
1557 $fields['oi_user'] = 'img_user';
1558 $fields['oi_user_text'] = 'img_user_text';
1559 }
1560 if ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW ) {
1561 $fields['oi_actor'] = 'img_actor';
1562 }
1563
1564 if (
1565 ( $wgActorTableSchemaMigrationStage & SCHEMA_COMPAT_WRITE_BOTH ) === SCHEMA_COMPAT_WRITE_BOTH
1566 ) {
1567 // Upgrade any rows that are still old-style. Otherwise an upgrade
1568 // might be missed if a deletion happens while the migration script
1569 // is running.
1570 $res = $dbw->select(
1571 [ 'image' ],
1572 [ 'img_name', 'img_user', 'img_user_text' ],
1573 [ 'img_name' => $this->getName(), 'img_actor' => 0 ],
1574 __METHOD__
1575 );
1576 foreach ( $res as $row ) {
1577 $actorId = User::newFromAnyId( $row->img_user, $row->img_user_text, null )->getActorId( $dbw );
1578 $dbw->update(
1579 'image',
1580 [ 'img_actor' => $actorId ],
1581 [ 'img_name' => $row->img_name, 'img_actor' => 0 ],
1582 __METHOD__
1583 );
1584 }
1585 }
1586
1587 # (T36993) Note: $oldver can be empty here, if the previous
1588 # version of the file was broken. Allow registration of the new
1589 # version to continue anyway, because that's better than having
1590 # an image that's not fixable by user operations.
1591 # Collision, this is an update of a file
1592 # Insert previous contents into oldimage
1593 $dbw->insertSelect( 'oldimage', $tables, $fields,
1594 [ 'img_name' => $this->getName() ], __METHOD__, [], [], $joins );
1595
1596 # Update the current image row
1597 $dbw->update( 'image',
1598 [
1599 'img_size' => $this->size,
1600 'img_width' => intval( $this->width ),
1601 'img_height' => intval( $this->height ),
1602 'img_bits' => $this->bits,
1603 'img_media_type' => $this->media_type,
1604 'img_major_mime' => $this->major_mime,
1605 'img_minor_mime' => $this->minor_mime,
1606 'img_timestamp' => $timestamp,
1607 'img_metadata' => $dbw->encodeBlob( $this->metadata ),
1608 'img_sha1' => $this->sha1
1609 ] + $commentFields + $actorFields,
1610 [ 'img_name' => $this->getName() ],
1611 __METHOD__
1612 );
1613 }
1614
1615 $descTitle = $this->getTitle();
1616 $descId = $descTitle->getArticleID();
1617 $wikiPage = new WikiFilePage( $descTitle );
1618 $wikiPage->setFile( $this );
1619
1620 // Determine log action. If reupload is done by reverting, use a special log_action.
1621 if ( $revert === true ) {
1622 $logAction = 'revert';
1623 } elseif ( $reupload === true ) {
1624 $logAction = 'overwrite';
1625 } else {
1626 $logAction = 'upload';
1627 }
1628 // Add the log entry...
1629 $logEntry = new ManualLogEntry( 'upload', $logAction );
1630 $logEntry->setTimestamp( $this->timestamp );
1631 $logEntry->setPerformer( $user );
1632 $logEntry->setComment( $comment );
1633 $logEntry->setTarget( $descTitle );
1634 // Allow people using the api to associate log entries with the upload.
1635 // Log has a timestamp, but sometimes different from upload timestamp.
1636 $logEntry->setParameters(
1637 [
1638 'img_sha1' => $this->sha1,
1639 'img_timestamp' => $timestamp,
1640 ]
1641 );
1642 // Note we keep $logId around since during new image
1643 // creation, page doesn't exist yet, so log_page = 0
1644 // but we want it to point to the page we're making,
1645 // so we later modify the log entry.
1646 // For a similar reason, we avoid making an RC entry
1647 // now and wait until the page exists.
1648 $logId = $logEntry->insert();
1649
1650 if ( $descTitle->exists() ) {
1651 // Use own context to get the action text in content language
1652 $formatter = LogFormatter::newFromEntry( $logEntry );
1653 $formatter->setContext( RequestContext::newExtraneousContext( $descTitle ) );
1654 $editSummary = $formatter->getPlainActionText();
1655
1656 $nullRevision = $createNullRevision === false ? null : Revision::newNullRevision(
1657 $dbw,
1658 $descId,
1659 $editSummary,
1660 false,
1661 $user
1662 );
1663 if ( $nullRevision ) {
1664 $nullRevision->insertOn( $dbw );
1665 Hooks::run(
1666 'NewRevisionFromEditComplete',
1667 [ $wikiPage, $nullRevision, $nullRevision->getParentId(), $user ]
1668 );
1669 $wikiPage->updateRevisionOn( $dbw, $nullRevision );
1670 // Associate null revision id
1671 $logEntry->setAssociatedRevId( $nullRevision->getId() );
1672 }
1673
1674 $newPageContent = null;
1675 } else {
1676 // Make the description page and RC log entry post-commit
1677 $newPageContent = ContentHandler::makeContent( $pageText, $descTitle );
1678 }
1679
1680 # Defer purges, page creation, and link updates in case they error out.
1681 # The most important thing is that files and the DB registry stay synced.
1682 $dbw->endAtomic( __METHOD__ );
1683 $fname = __METHOD__;
1684
1685 # Do some cache purges after final commit so that:
1686 # a) Changes are more likely to be seen post-purge
1687 # b) They won't cause rollback of the log publish/update above
1688 DeferredUpdates::addUpdate(
1689 new AutoCommitUpdate(
1690 $dbw,
1691 __METHOD__,
1692 function () use (
1693 $reupload, $wikiPage, $newPageContent, $comment, $user,
1694 $logEntry, $logId, $descId, $tags, $fname
1695 ) {
1696 # Update memcache after the commit
1697 $this->invalidateCache();
1698
1699 $updateLogPage = false;
1700 if ( $newPageContent ) {
1701 # New file page; create the description page.
1702 # There's already a log entry, so don't make a second RC entry
1703 # CDN and file cache for the description page are purged by doEditContent.
1704 $status = $wikiPage->doEditContent(
1705 $newPageContent,
1706 $comment,
1707 EDIT_NEW | EDIT_SUPPRESS_RC,
1708 false,
1709 $user
1710 );
1711
1712 if ( isset( $status->value['revision'] ) ) {
1713 /** @var Revision $rev */
1714 $rev = $status->value['revision'];
1715 // Associate new page revision id
1716 $logEntry->setAssociatedRevId( $rev->getId() );
1717 }
1718 // This relies on the resetArticleID() call in WikiPage::insertOn(),
1719 // which is triggered on $descTitle by doEditContent() above.
1720 if ( isset( $status->value['revision'] ) ) {
1721 /** @var Revision $rev */
1722 $rev = $status->value['revision'];
1723 $updateLogPage = $rev->getPage();
1724 }
1725 } else {
1726 # Existing file page: invalidate description page cache
1727 $wikiPage->getTitle()->invalidateCache();
1728 $wikiPage->getTitle()->purgeSquid();
1729 # Allow the new file version to be patrolled from the page footer
1730 Article::purgePatrolFooterCache( $descId );
1731 }
1732
1733 # Update associated rev id. This should be done by $logEntry->insert() earlier,
1734 # but setAssociatedRevId() wasn't called at that point yet...
1735 $logParams = $logEntry->getParameters();
1736 $logParams['associated_rev_id'] = $logEntry->getAssociatedRevId();
1737 $update = [ 'log_params' => LogEntryBase::makeParamBlob( $logParams ) ];
1738 if ( $updateLogPage ) {
1739 # Also log page, in case where we just created it above
1740 $update['log_page'] = $updateLogPage;
1741 }
1742 $this->getRepo()->getMasterDB()->update(
1743 'logging',
1744 $update,
1745 [ 'log_id' => $logId ],
1746 $fname
1747 );
1748 $this->getRepo()->getMasterDB()->insert(
1749 'log_search',
1750 [
1751 'ls_field' => 'associated_rev_id',
1752 'ls_value' => $logEntry->getAssociatedRevId(),
1753 'ls_log_id' => $logId,
1754 ],
1755 $fname
1756 );
1757
1758 # Add change tags, if any
1759 if ( $tags ) {
1760 $logEntry->setTags( $tags );
1761 }
1762
1763 # Uploads can be patrolled
1764 $logEntry->setIsPatrollable( true );
1765
1766 # Now that the log entry is up-to-date, make an RC entry.
1767 $logEntry->publish( $logId );
1768
1769 # Run hook for other updates (typically more cache purging)
1770 Hooks::run( 'FileUpload', [ $this, $reupload, !$newPageContent ] );
1771
1772 if ( $reupload ) {
1773 # Delete old thumbnails
1774 $this->purgeThumbnails();
1775 # Remove the old file from the CDN cache
1776 DeferredUpdates::addUpdate(
1777 new CdnCacheUpdate( [ $this->getUrl() ] ),
1778 DeferredUpdates::PRESEND
1779 );
1780 } else {
1781 # Update backlink pages pointing to this title if created
1782 LinksUpdate::queueRecursiveJobsForTable(
1783 $this->getTitle(),
1784 'imagelinks',
1785 'upload-image',
1786 $user->getName()
1787 );
1788 }
1789
1790 $this->prerenderThumbnails();
1791 }
1792 ),
1793 DeferredUpdates::PRESEND
1794 );
1795
1796 if ( !$reupload ) {
1797 # This is a new file, so update the image count
1798 DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'images' => 1 ] ) );
1799 }
1800
1801 # Invalidate cache for all pages using this file
1802 DeferredUpdates::addUpdate(
1803 new HTMLCacheUpdate( $this->getTitle(), 'imagelinks', 'file-upload' )
1804 );
1805
1806 return Status::newGood();
1807 }
1808
1809 /**
1810 * Move or copy a file to its public location. If a file exists at the
1811 * destination, move it to an archive. Returns a Status object with
1812 * the archive name in the "value" member on success.
1813 *
1814 * The archive name should be passed through to recordUpload for database
1815 * registration.
1816 *
1817 * @param string|FSFile $src Local filesystem path or virtual URL to the source image
1818 * @param int $flags A bitwise combination of:
1819 * File::DELETE_SOURCE Delete the source file, i.e. move rather than copy
1820 * @param array $options Optional additional parameters
1821 * @return Status On success, the value member contains the
1822 * archive name, or an empty string if it was a new file.
1823 */
1824 function publish( $src, $flags = 0, array $options = [] ) {
1825 return $this->publishTo( $src, $this->getRel(), $flags, $options );
1826 }
1827
1828 /**
1829 * Move or copy a file to a specified location. Returns a Status
1830 * object with the archive name in the "value" member on success.
1831 *
1832 * The archive name should be passed through to recordUpload for database
1833 * registration.
1834 *
1835 * @param string|FSFile $src Local filesystem path or virtual URL to the source image
1836 * @param string $dstRel Target relative path
1837 * @param int $flags A bitwise combination of:
1838 * File::DELETE_SOURCE Delete the source file, i.e. move rather than copy
1839 * @param array $options Optional additional parameters
1840 * @return Status On success, the value member contains the
1841 * archive name, or an empty string if it was a new file.
1842 */
1843 function publishTo( $src, $dstRel, $flags = 0, array $options = [] ) {
1844 $srcPath = ( $src instanceof FSFile ) ? $src->getPath() : $src;
1845
1846 $repo = $this->getRepo();
1847 if ( $repo->getReadOnlyReason() !== false ) {
1848 return $this->readOnlyFatalStatus();
1849 }
1850
1851 $this->lock();
1852
1853 if ( $this->isOld() ) {
1854 $archiveRel = $dstRel;
1855 $archiveName = basename( $archiveRel );
1856 } else {
1857 $archiveName = wfTimestamp( TS_MW ) . '!' . $this->getName();
1858 $archiveRel = $this->getArchiveRel( $archiveName );
1859 }
1860
1861 if ( $repo->hasSha1Storage() ) {
1862 $sha1 = FileRepo::isVirtualUrl( $srcPath )
1863 ? $repo->getFileSha1( $srcPath )
1864 : FSFile::getSha1Base36FromPath( $srcPath );
1865 /** @var FileBackendDBRepoWrapper $wrapperBackend */
1866 $wrapperBackend = $repo->getBackend();
1867 $dst = $wrapperBackend->getPathForSHA1( $sha1 );
1868 $status = $repo->quickImport( $src, $dst );
1869 if ( $flags & File::DELETE_SOURCE ) {
1870 unlink( $srcPath );
1871 }
1872
1873 if ( $this->exists() ) {
1874 $status->value = $archiveName;
1875 }
1876 } else {
1877 $flags = $flags & File::DELETE_SOURCE ? LocalRepo::DELETE_SOURCE : 0;
1878 $status = $repo->publish( $srcPath, $dstRel, $archiveRel, $flags, $options );
1879
1880 if ( $status->value == 'new' ) {
1881 $status->value = '';
1882 } else {
1883 $status->value = $archiveName;
1884 }
1885 }
1886
1887 $this->unlock();
1888 return $status;
1889 }
1890
1891 /** getLinksTo inherited */
1892 /** getExifData inherited */
1893 /** isLocal inherited */
1894 /** wasDeleted inherited */
1895
1896 /**
1897 * Move file to the new title
1898 *
1899 * Move current, old version and all thumbnails
1900 * to the new filename. Old file is deleted.
1901 *
1902 * Cache purging is done; checks for validity
1903 * and logging are caller's responsibility
1904 *
1905 * @param Title $target New file name
1906 * @return Status
1907 */
1908 function move( $target ) {
1909 $localRepo = MediaWikiServices::getInstance()->getRepoGroup();
1910 if ( $this->getRepo()->getReadOnlyReason() !== false ) {
1911 return $this->readOnlyFatalStatus();
1912 }
1913
1914 wfDebugLog( 'imagemove', "Got request to move {$this->name} to " . $target->getText() );
1915 $batch = new LocalFileMoveBatch( $this, $target );
1916
1917 $this->lock();
1918 $batch->addCurrent();
1919 $archiveNames = $batch->addOlds();
1920 $status = $batch->execute();
1921 $this->unlock();
1922
1923 wfDebugLog( 'imagemove', "Finished moving {$this->name}" );
1924
1925 // Purge the source and target files...
1926 $oldTitleFile = $localRepo->findFile( $this->title );
1927 $newTitleFile = $localRepo->findFile( $target );
1928 // To avoid slow purges in the transaction, move them outside...
1929 DeferredUpdates::addUpdate(
1930 new AutoCommitUpdate(
1931 $this->getRepo()->getMasterDB(),
1932 __METHOD__,
1933 function () use ( $oldTitleFile, $newTitleFile, $archiveNames ) {
1934 $oldTitleFile->purgeEverything();
1935 foreach ( $archiveNames as $archiveName ) {
1936 $oldTitleFile->purgeOldThumbnails( $archiveName );
1937 }
1938 $newTitleFile->purgeEverything();
1939 }
1940 ),
1941 DeferredUpdates::PRESEND
1942 );
1943
1944 if ( $status->isOK() ) {
1945 // Now switch the object
1946 $this->title = $target;
1947 // Force regeneration of the name and hashpath
1948 unset( $this->name );
1949 unset( $this->hashPath );
1950 }
1951
1952 return $status;
1953 }
1954
1955 /**
1956 * Delete all versions of the file.
1957 *
1958 * Moves the files into an archive directory (or deletes them)
1959 * and removes the database rows.
1960 *
1961 * Cache purging is done; logging is caller's responsibility.
1962 *
1963 * @param string $reason
1964 * @param bool $suppress
1965 * @param User|null $user
1966 * @return Status
1967 */
1968 function delete( $reason, $suppress = false, $user = null ) {
1969 if ( $this->getRepo()->getReadOnlyReason() !== false ) {
1970 return $this->readOnlyFatalStatus();
1971 }
1972
1973 $batch = new LocalFileDeleteBatch( $this, $reason, $suppress, $user );
1974
1975 $this->lock();
1976 $batch->addCurrent();
1977 // Get old version relative paths
1978 $archiveNames = $batch->addOlds();
1979 $status = $batch->execute();
1980 $this->unlock();
1981
1982 if ( $status->isOK() ) {
1983 DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'images' => -1 ] ) );
1984 }
1985
1986 // To avoid slow purges in the transaction, move them outside...
1987 DeferredUpdates::addUpdate(
1988 new AutoCommitUpdate(
1989 $this->getRepo()->getMasterDB(),
1990 __METHOD__,
1991 function () use ( $archiveNames ) {
1992 $this->purgeEverything();
1993 foreach ( $archiveNames as $archiveName ) {
1994 $this->purgeOldThumbnails( $archiveName );
1995 }
1996 }
1997 ),
1998 DeferredUpdates::PRESEND
1999 );
2000
2001 // Purge the CDN
2002 $purgeUrls = [];
2003 foreach ( $archiveNames as $archiveName ) {
2004 $purgeUrls[] = $this->getArchiveUrl( $archiveName );
2005 }
2006 DeferredUpdates::addUpdate( new CdnCacheUpdate( $purgeUrls ), DeferredUpdates::PRESEND );
2007
2008 return $status;
2009 }
2010
2011 /**
2012 * Delete an old version of the file.
2013 *
2014 * Moves the file into an archive directory (or deletes it)
2015 * and removes the database row.
2016 *
2017 * Cache purging is done; logging is caller's responsibility.
2018 *
2019 * @param string $archiveName
2020 * @param string $reason
2021 * @param bool $suppress
2022 * @param User|null $user
2023 * @throws MWException Exception on database or file store failure
2024 * @return Status
2025 */
2026 function deleteOld( $archiveName, $reason, $suppress = false, $user = null ) {
2027 if ( $this->getRepo()->getReadOnlyReason() !== false ) {
2028 return $this->readOnlyFatalStatus();
2029 }
2030
2031 $batch = new LocalFileDeleteBatch( $this, $reason, $suppress, $user );
2032
2033 $this->lock();
2034 $batch->addOld( $archiveName );
2035 $status = $batch->execute();
2036 $this->unlock();
2037
2038 $this->purgeOldThumbnails( $archiveName );
2039 if ( $status->isOK() ) {
2040 $this->purgeDescription();
2041 }
2042
2043 DeferredUpdates::addUpdate(
2044 new CdnCacheUpdate( [ $this->getArchiveUrl( $archiveName ) ] ),
2045 DeferredUpdates::PRESEND
2046 );
2047
2048 return $status;
2049 }
2050
2051 /**
2052 * Restore all or specified deleted revisions to the given file.
2053 * Permissions and logging are left to the caller.
2054 *
2055 * May throw database exceptions on error.
2056 *
2057 * @param array $versions Set of record ids of deleted items to restore,
2058 * or empty to restore all revisions.
2059 * @param bool $unsuppress
2060 * @return Status
2061 */
2062 function restore( $versions = [], $unsuppress = false ) {
2063 if ( $this->getRepo()->getReadOnlyReason() !== false ) {
2064 return $this->readOnlyFatalStatus();
2065 }
2066
2067 $batch = new LocalFileRestoreBatch( $this, $unsuppress );
2068
2069 $this->lock();
2070 if ( !$versions ) {
2071 $batch->addAll();
2072 } else {
2073 $batch->addIds( $versions );
2074 }
2075 $status = $batch->execute();
2076 if ( $status->isGood() ) {
2077 $cleanupStatus = $batch->cleanup();
2078 $cleanupStatus->successCount = 0;
2079 $cleanupStatus->failCount = 0;
2080 $status->merge( $cleanupStatus );
2081 }
2082
2083 $this->unlock();
2084 return $status;
2085 }
2086
2087 /** isMultipage inherited */
2088 /** pageCount inherited */
2089 /** scaleHeight inherited */
2090 /** getImageSize inherited */
2091
2092 /**
2093 * Get the URL of the file description page.
2094 * @return string
2095 */
2096 function getDescriptionUrl() {
2097 return $this->title->getLocalURL();
2098 }
2099
2100 /**
2101 * Get the HTML text of the description page
2102 * This is not used by ImagePage for local files, since (among other things)
2103 * it skips the parser cache.
2104 *
2105 * @param Language|null $lang What language to get description in (Optional)
2106 * @return string|false
2107 */
2108 function getDescriptionText( Language $lang = null ) {
2109 $store = MediaWikiServices::getInstance()->getRevisionStore();
2110 $revision = $store->getRevisionByTitle( $this->title, 0, Revision::READ_NORMAL );
2111 if ( !$revision ) {
2112 return false;
2113 }
2114
2115 $renderer = MediaWikiServices::getInstance()->getRevisionRenderer();
2116 $rendered = $renderer->getRenderedRevision( $revision, new ParserOptions( null, $lang ) );
2117
2118 if ( !$rendered ) {
2119 // audience check failed
2120 return false;
2121 }
2122
2123 $pout = $rendered->getRevisionParserOutput();
2124 return $pout->getText();
2125 }
2126
2127 /**
2128 * @param int $audience
2129 * @param User|null $user
2130 * @return string
2131 */
2132 function getDescription( $audience = self::FOR_PUBLIC, User $user = null ) {
2133 $this->load();
2134 if ( $audience == self::FOR_PUBLIC && $this->isDeleted( self::DELETED_COMMENT ) ) {
2135 return '';
2136 } elseif ( $audience == self::FOR_THIS_USER
2137 && !$this->userCan( self::DELETED_COMMENT, $user )
2138 ) {
2139 return '';
2140 } else {
2141 return $this->description;
2142 }
2143 }
2144
2145 /**
2146 * @return bool|string
2147 */
2148 function getTimestamp() {
2149 $this->load();
2150
2151 return $this->timestamp;
2152 }
2153
2154 /**
2155 * @return bool|string
2156 */
2157 public function getDescriptionTouched() {
2158 // The DB lookup might return false, e.g. if the file was just deleted, or the shared DB repo
2159 // itself gets it from elsewhere. To avoid repeating the DB lookups in such a case, we
2160 // need to differentiate between null (uninitialized) and false (failed to load).
2161 if ( $this->descriptionTouched === null ) {
2162 $cond = [
2163 'page_namespace' => $this->title->getNamespace(),
2164 'page_title' => $this->title->getDBkey()
2165 ];
2166 $touched = $this->repo->getReplicaDB()->selectField( 'page', 'page_touched', $cond, __METHOD__ );
2167 $this->descriptionTouched = $touched ? wfTimestamp( TS_MW, $touched ) : false;
2168 }
2169
2170 return $this->descriptionTouched;
2171 }
2172
2173 /**
2174 * @return string
2175 */
2176 function getSha1() {
2177 $this->load();
2178 // Initialise now if necessary
2179 if ( $this->sha1 == '' && $this->fileExists ) {
2180 $this->lock();
2181
2182 $this->sha1 = $this->repo->getFileSha1( $this->getPath() );
2183 if ( !wfReadOnly() && strval( $this->sha1 ) != '' ) {
2184 $dbw = $this->repo->getMasterDB();
2185 $dbw->update( 'image',
2186 [ 'img_sha1' => $this->sha1 ],
2187 [ 'img_name' => $this->getName() ],
2188 __METHOD__ );
2189 $this->invalidateCache();
2190 }
2191
2192 $this->unlock();
2193 }
2194
2195 return $this->sha1;
2196 }
2197
2198 /**
2199 * @return bool Whether to cache in RepoGroup (this avoids OOMs)
2200 */
2201 function isCacheable() {
2202 $this->load();
2203
2204 // If extra data (metadata) was not loaded then it must have been large
2205 return $this->extraDataLoaded
2206 && strlen( serialize( $this->metadata ) ) <= self::CACHE_FIELD_MAX_LEN;
2207 }
2208
2209 /**
2210 * @return Status
2211 * @since 1.28
2212 */
2213 public function acquireFileLock() {
2214 return Status::wrap( $this->getRepo()->getBackend()->lockFiles(
2215 [ $this->getPath() ], LockManager::LOCK_EX, 10
2216 ) );
2217 }
2218
2219 /**
2220 * @return Status
2221 * @since 1.28
2222 */
2223 public function releaseFileLock() {
2224 return Status::wrap( $this->getRepo()->getBackend()->unlockFiles(
2225 [ $this->getPath() ], LockManager::LOCK_EX
2226 ) );
2227 }
2228
2229 /**
2230 * Start an atomic DB section and lock the image for update
2231 * or increments a reference counter if the lock is already held
2232 *
2233 * This method should not be used outside of LocalFile/LocalFile*Batch
2234 *
2235 * @throws LocalFileLockError Throws an error if the lock was not acquired
2236 * @return bool Whether the file lock owns/spawned the DB transaction
2237 */
2238 public function lock() {
2239 if ( !$this->locked ) {
2240 $logger = LoggerFactory::getInstance( 'LocalFile' );
2241
2242 $dbw = $this->repo->getMasterDB();
2243 $makesTransaction = !$dbw->trxLevel();
2244 $dbw->startAtomic( self::ATOMIC_SECTION_LOCK );
2245 // T56736: use simple lock to handle when the file does not exist.
2246 // SELECT FOR UPDATE prevents changes, not other SELECTs with FOR UPDATE.
2247 // Also, that would cause contention on INSERT of similarly named rows.
2248 $status = $this->acquireFileLock(); // represents all versions of the file
2249 if ( !$status->isGood() ) {
2250 $dbw->endAtomic( self::ATOMIC_SECTION_LOCK );
2251 $logger->warning( "Failed to lock '{file}'", [ 'file' => $this->name ] );
2252
2253 throw new LocalFileLockError( $status );
2254 }
2255 // Release the lock *after* commit to avoid row-level contention.
2256 // Make sure it triggers on rollback() as well as commit() (T132921).
2257 $dbw->onTransactionResolution(
2258 function () use ( $logger ) {
2259 $status = $this->releaseFileLock();
2260 if ( !$status->isGood() ) {
2261 $logger->error( "Failed to unlock '{file}'", [ 'file' => $this->name ] );
2262 }
2263 },
2264 __METHOD__
2265 );
2266 // Callers might care if the SELECT snapshot is safely fresh
2267 $this->lockedOwnTrx = $makesTransaction;
2268 }
2269
2270 $this->locked++;
2271
2272 return $this->lockedOwnTrx;
2273 }
2274
2275 /**
2276 * Decrement the lock reference count and end the atomic section if it reaches zero
2277 *
2278 * This method should not be used outside of LocalFile/LocalFile*Batch
2279 *
2280 * The commit and loc release will happen when no atomic sections are active, which
2281 * may happen immediately or at some point after calling this
2282 */
2283 public function unlock() {
2284 if ( $this->locked ) {
2285 --$this->locked;
2286 if ( !$this->locked ) {
2287 $dbw = $this->repo->getMasterDB();
2288 $dbw->endAtomic( self::ATOMIC_SECTION_LOCK );
2289 $this->lockedOwnTrx = false;
2290 }
2291 }
2292 }
2293
2294 /**
2295 * @return Status
2296 */
2297 protected function readOnlyFatalStatus() {
2298 return $this->getRepo()->newFatal( 'filereadonlyerror', $this->getName(),
2299 $this->getRepo()->getName(), $this->getRepo()->getReadOnlyReason() );
2300 }
2301
2302 /**
2303 * Clean up any dangling locks
2304 */
2305 function __destruct() {
2306 $this->unlock();
2307 }
2308 }