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