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