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