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